diff --git a/.github/workflows/ansible-deploy.yml b/.github/workflows/ansible-deploy.yml new file mode 100644 index 0000000000..12c1e2d599 --- /dev/null +++ b/.github/workflows/ansible-deploy.yml @@ -0,0 +1,82 @@ +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/**' + - '!ansible/docs/**' + +jobs: + lint: + name: Ansible Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install ansible-lint + run: pip install ansible ansible-lint + + - name: Run ansible-lint + run: | + cd ansible + ansible-lint playbooks/*.yml + continue-on-error: true + + deploy: + name: Deploy Application + needs: lint + runs-on: ubuntu-latest + if: github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Ansible + run: pip install ansible + + - 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: Create inventory + run: | + cd ansible + mkdir -p inventory + cat > inventory/hosts.ini < /tmp/vault_pass + ansible-playbook playbooks/deploy.yml \ + -i inventory/hosts.ini \ + --vault-password-file /tmp/vault_pass + rm -f /tmp/vault_pass + + - name: Verify Deployment + run: | + sleep 10 + curl -f http://${{ secrets.VM_HOST }}:5000 || exit 1 + curl -f http://${{ secrets.VM_HOST }}:5000/health || exit 1 diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..31abf8a311 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,105 @@ +name: Python CI + +on: + push: + branches: [main, master, lab03] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + pull_request: + branches: [main, master] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + +env: + DOCKER_IMAGE: dmitry567/devops-info-service + +jobs: + test: + name: Lint & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: app_python + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: 'pip' + cache-dependency-path: | + app_python/requirements.txt + app_python/requirements-dev.txt + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Lint with ruff + run: ruff check . + + - name: Run tests + run: pytest tests/ -v --cov=. --cov-report=term --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: app_python/coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + security: + name: Security Scan + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Run Snyk to check for vulnerabilities + uses: snyk/actions/python@master + continue-on-error: true + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --file=app_python/requirements.txt --severity-threshold=high + + docker: + name: Build & Push Docker + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' + + steps: + - uses: actions/checkout@v4 + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKER_IMAGE }} + tags: | + type=raw,value=latest + type=raw,value={{date 'YYYY.MM'}}.{{sha}} + type=raw,value={{date 'YYYY.MM'}} + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: app_python + push: true + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:latest + cache-to: type=inline + diff --git a/.github/workflows/terraform-ci.yml b/.github/workflows/terraform-ci.yml new file mode 100644 index 0000000000..a26fa96215 --- /dev/null +++ b/.github/workflows/terraform-ci.yml @@ -0,0 +1,49 @@ +name: Terraform CI + +on: + push: + branches: [main, master, lab04] + paths: + - 'terraform/**' + - '.github/workflows/terraform-ci.yml' + pull_request: + branches: [main, master] + paths: + - 'terraform/**' + - '.github/workflows/terraform-ci.yml' + +jobs: + validate: + name: Validate Terraform + runs-on: ubuntu-latest + defaults: + run: + working-directory: terraform + + steps: + - uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.5.7 + + - name: Terraform Format Check + run: terraform fmt -check + + - 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 --init + tflint --format compact + diff --git a/.gitignore b/.gitignore index 30d74d2584..32d412c6c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -test \ No newline at end of file +test +.idea \ No newline at end of file diff --git a/ansible/.gitignore b/ansible/.gitignore new file mode 100644 index 0000000000..3377fed8f6 --- /dev/null +++ b/ansible/.gitignore @@ -0,0 +1,5 @@ +*.retry +.vault_pass +__pycache__/ +*.pyc + diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..d1637102bc --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,13 @@ +[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..330582a731 --- /dev/null +++ b/ansible/docs/LAB05.md @@ -0,0 +1,153 @@ +# Lab 05 — Ansible Fundamentals + +## Architecture Overview + +- **Ansible**: 13.4.0 (ansible-core 2.20) +- **Target VM**: Ubuntu 24.04 LTS (Yandex Cloud, 93.77.177.221) +- **Roles**: common, docker, app_deploy +- **Vault**: credentials encrypted with `ansible-vault` + +### Structure + +``` +ansible/ +├── inventory/hosts.ini +├── roles/ +│ ├── common/ # System packages, timezone +│ ├── docker/ # Docker CE installation +│ └── app_deploy/ # Pull & run container +├── playbooks/ +│ ├── provision.yml # common + docker +│ ├── deploy.yml # app_deploy +│ └── site.yml # all roles +├── group_vars/all.yml # Vault-encrypted credentials +├── ansible.cfg +└── .vault_pass # Gitignored +``` + +**Why roles?** Reusable, modular, testable independently. Each role has single responsibility. + +--- + +## Roles + +### common +- **Purpose**: Install essential packages, set timezone +- **Variables**: `common_packages` (list), `timezone` +- **Handlers**: none +- **Dependencies**: none + +### docker +- **Purpose**: Install Docker CE from official repo +- **Variables**: `docker_user`, `docker_packages` +- **Handlers**: `restart docker` +- **Dependencies**: common (runs before docker) + +### app_deploy +- **Purpose**: Pull Docker image, run container, health check +- **Variables**: `docker_image`, `docker_image_tag`, `app_port`, `app_container_name`, `app_restart_policy` +- **Handlers**: `restart app container` +- **Dependencies**: docker (must be installed first) + +--- + +## Idempotency + +### First run (changed=4) + +``` +TASK [docker : Install Docker packages] changed +TASK [docker : Add user to docker group] changed +TASK [docker : Install python3-docker] changed +HANDLER [docker : restart docker] changed +PLAY RECAP: ok=12 changed=4 +``` + +### Second run (changed=0) + +``` +PLAY RECAP: ok=11 changed=0 +``` + +All tasks green — desired state already achieved. Modules like `apt: state=present` and `service: state=started` only act when needed. + +--- + +## Ansible Vault + +```bash +ansible-vault create group_vars/all.yml # Create encrypted file +ansible-vault edit group_vars/all.yml # Edit +ansible-vault view group_vars/all.yml # View +``` + +Encrypted file is safe to commit. Vault password stored in `.vault_pass` (gitignored). + +Variables stored: `dockerhub_username`, `dockerhub_password`, `docker_image`, `app_port`, `app_container_name`. + +--- + +## Deployment Verification + +``` +$ ansible-playbook playbooks/deploy.yml +TASK [app_deploy : Pull Docker image] ok +TASK [app_deploy : Run application container] changed +TASK [app_deploy : Wait for application to be ready] ok +TASK [app_deploy : Verify health endpoint] ok +TASK [app_deploy : Show health check result] ok + health_check.json: {"status": "healthy", "timestamp": "2026-02-26T20:28:47", "uptime_seconds": 7} + +$ docker ps +CONTAINER ID IMAGE STATUS PORTS NAMES +52fa54537f86 dmitry567/devops-info-service:latest Up 1 minute 0.0.0.0:5000->5000/tcp devops-app + +$ curl http://93.77.177.221:5000/health +{"status": "healthy", "timestamp": "...", "uptime_seconds": 38} +``` + +--- + +## Key Decisions + +- **Roles vs playbooks**: Roles encapsulate logic, defaults, handlers in one place. Easy to reuse across projects. +- **Reusability**: Docker role can be used in any project needing Docker. Variables in `defaults/` allow customization. +- **Idempotency**: Using `state: present` (not shell commands). Modules check current state before acting. +- **Handlers**: Only restart Docker when packages change. Avoids unnecessary restarts. +- **Vault**: Secrets encrypted at rest. Can be committed to Git safely. Decrypted only at runtime. + +--- + +## Bonus: Dynamic Inventory + +### Plugin / Script + +Custom Python script (`inventory/yandex_cloud.py`) that queries Yandex Cloud API via `yc` CLI. + +- Groups VMs by label `project=devops-course` → `webservers` group +- Maps public IP → `ansible_host` +- Sets `ansible_user=ubuntu` +- Filters only RUNNING instances + +### `ansible-inventory --graph` + +``` +@all: + |--@ungrouped: + |--@webservers: + | |--devops-vm +``` + +### Playbook run with dynamic inventory + +```bash +ansible-playbook -i inventory/yandex_cloud.py playbooks/deploy.yml +# PLAY RECAP: ok=9 changed=3 — app deployed, health check passed +``` + +### Benefits vs static inventory + +- **No manual IP updates** — when VM IP changes, script queries fresh data +- **Auto-discovery** — new VMs with `project=devops-course` label appear automatically +- **Scalability** — works for 1 VM or 100 VMs without config changes + diff --git a/ansible/docs/LAB06.md b/ansible/docs/LAB06.md new file mode 100644 index 0000000000..61158bd02e --- /dev/null +++ b/ansible/docs/LAB06.md @@ -0,0 +1,182 @@ +# Lab 6: Advanced Ansible & CI/CD + +## Task 1: Blocks & Tags + +### Common Role + +Roles refactored with `block / rescue / always`: + +- **packages** block — apt update + install; rescue runs `apt-get update --fix-missing`; always writes timestamp to `/tmp/ansible_common_done`. +- **users** block — timezone setup. +- Role-level tag `common` applied in `provision.yml`. + +### Docker Role + +- **docker_install** block — GPG key, repo, packages; rescue waits 10s and retries; always ensures Docker service is enabled. +- **docker_config** block — user group membership. +- Role-level tag `docker` applied in `provision.yml`. + +### Tag Strategy + +| Tag | Scope | +|-----|-------| +| `common` | entire common role | +| `packages` | apt packages only | +| `users` | user/timezone config | +| `docker` | entire docker role | +| `docker_install` | Docker packages only | +| `docker_config` | Docker user config | +| `app_deploy` | application deployment | +| `compose` | Docker Compose tasks | +| `web_app_wipe` | wipe tasks | + +### Selective Execution + +```bash +ansible-playbook playbooks/provision.yml --tags "docker" +ansible-playbook playbooks/provision.yml --skip-tags "common" +ansible-playbook playbooks/provision.yml --list-tags +``` + +### Research + +- **Rescue also fails?** Ansible reports fatal error; `always` still executes. +- **Nested blocks?** Yes, blocks can contain blocks. +- **Tag inheritance?** Tasks inside a block inherit tags applied to the block. + +--- + +## Task 2: Docker Compose + +### Migration from `docker run` + +Renamed `app_deploy` → `web_app`. Added Jinja2 template `docker-compose.yml.j2`: + +```yaml +version: '3.8' +services: + {{ app_name }}: + image: {{ docker_image }}:{{ docker_tag }} + container_name: {{ app_name }} + ports: + - "{{ app_port }}:{{ app_internal_port }}" + environment: + PORT: "{{ app_internal_port }}" + restart: unless-stopped +``` + +### Role Dependencies + +`roles/web_app/meta/main.yml` declares `docker` as a dependency — running only the `web_app` role auto-installs Docker first. + +### Deployment Flow + +1. Create `/opt/{{ app_name }}` directory +2. Template `docker-compose.yml` +3. `docker compose pull` + `docker compose up -d` +4. Wait for port + health check + +### Research + +- **`always` vs `unless-stopped`?** `always` restarts even after manual stop; `unless-stopped` does not. +- **Compose vs bridge networks?** Compose creates an isolated network per project; bridge is Docker's shared default. +- **Vault vars in templates?** Yes, Ansible decrypts them before Jinja2 rendering. + +--- + +## Task 3: Wipe Logic + +Double-gated by **variable** (`web_app_wipe: false` by default) and **tag** (`web_app_wipe`). + +### Scenarios + +| # | Command | Result | +|---|---------|--------| +| 1 | `ansible-playbook deploy.yml` | normal deploy, wipe skipped | +| 2 | `deploy.yml -e "web_app_wipe=true" --tags web_app_wipe` | wipe only | +| 3 | `deploy.yml -e "web_app_wipe=true"` | wipe → fresh deploy | +| 4 | `deploy.yml --tags web_app_wipe` | variable false → wipe skipped | + +### Wipe Actions + +1. `docker compose down --remove-orphans` +2. Remove compose file +3. Remove app directory + +### Research + +- **Why both variable AND tag?** Prevents accidental wipe — need explicit opt-in on both levels. +- **Difference from `never` tag?** `never` requires `--tags never` to run; this approach uses a custom tag + variable for finer control and documentation. +- **Why wipe before deploy?** Enables clean reinstall in a single playbook run. +- **Clean reinstall vs rolling update?** Clean removes all state; rolling keeps the service available during update. +- **Extending wipe?** Add `docker image prune -f` and `docker volume prune -f` tasks. + +--- + +## Task 4: CI/CD + +### Workflow: `.github/workflows/ansible-deploy.yml` + +**Jobs:** + +1. **lint** — installs `ansible-lint`, runs on all playbooks +2. **deploy** — sets up SSH, creates inventory from secrets, runs `ansible-playbook deploy.yml`, verifies with curl + +**Path filters:** triggers on `ansible/**` changes (excluding `docs/`). + +**GitHub Secrets required:** + +| Secret | Purpose | +|--------|---------| +| `SSH_PRIVATE_KEY` | SSH key to VM | +| `VM_HOST` | Target VM IP | +| `VM_USER` | SSH username | +| `ANSIBLE_VAULT_PASSWORD` | Vault decryption | + +**Badge:** + +``` +![Ansible Deployment](https://github.com/trunn5/DevOps-Core-Course/actions/workflows/ansible-deploy.yml/badge.svg) +``` + +### Research + +- **SSH keys in Secrets?** Encrypted at rest, exposed only to workflow runs. Rotate regularly; use deploy keys with minimal scope. +- **Staging → production?** Add separate inventory files and jobs with manual approval gate for production. +- **Rollbacks?** Pin `docker_tag` to specific version; revert the variable and re-run the playbook. +- **Self-hosted vs GitHub-hosted?** Self-hosted avoids exposing SSH keys externally; runner stays inside the network. + +--- + +## Bonus 1: Multi-App Deployment + +### Architecture + +Same `web_app` role deployed twice with different variables: + +| App | Image | Host Port | Internal Port | +|-----|-------|-----------|---------------| +| devops-python | dmitry567/devops-info-service | 5000 | 5000 | +| devops-scala | dmitry567/devops-info-service-scala | 5001 | 5000 | + +Variable files: `vars/app_python.yml`, `vars/app_scala.yml`. + +Playbooks: `deploy_python.yml`, `deploy_scala.yml`, `deploy_all.yml`. + +Wipe is app-specific — each playbook sets its own `app_name` / `compose_project_dir`. + +--- + +## Bonus 2: Multi-App CI/CD + +Separate workflows for each app can be created with path filters on `vars/app_python.yml` vs `vars/app_scala.yml`. Changes to `roles/web_app/**` trigger both workflows. + +--- + +## Summary + +- Roles refactored with blocks, rescue/always, and a comprehensive tag strategy +- Migrated from `docker run` to Docker Compose with Jinja2 templates +- Wipe logic with double-gate safety (variable + tag) +- CI/CD workflow with linting, deployment, and verification +- Multi-app support via role reusability with different variable files diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000000..94055ad9b1 --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,17 @@ +$ANSIBLE_VAULT;1.1;AES256 +33346166636637633338666435386130366137316135633332643263376233366237653131336266 +6632333362323435343265656435313436343430636135660a373737663438626537623333613833 +32383166383635306134646663343130623763653066373631333432343865356431346362386666 +6363663337346634300a356236353935636538326135303630653664326531663330646363643564 +37336131646238653233616463303130323965326465356530623935646235316239333039626431 +34663135663036373535646630346561643735666234323266386434333932343138373939663535 +64323336653466363063646531643032323962613837633134393731323633373463343833303933 +62326235656362666437363065633234363663393938303531326165666130336562613366626161 +36333438383637386437373230306162633966303365663965346137313465663539373836396431 +37326239313166626133616635303538353432346462313431393032666362393166396337303037 +61363761636136623538663861623965613939623935353865633838646633383532666335373534 +30653334663439346163306365643062343831613733623466343833303439333834386361363235 +65396266356232366566643863643562356363613931396537393464333137356261643539626636 +34393537643063373065333462346339323835366464623833663539383236353935363665326461 +38303965333734646136366432343962653430383930633138646138363336336235316330623865 +36326261666264643830 diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..58fe2299f9 --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,3 @@ +[webservers] +devops-vm ansible_host=93.77.177.221 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_ed25519 + diff --git a/ansible/inventory/yandex_cloud.py b/ansible/inventory/yandex_cloud.py new file mode 100755 index 0000000000..1be33fa888 --- /dev/null +++ b/ansible/inventory/yandex_cloud.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +""" +Dynamic inventory script for Yandex Cloud. +Queries running VMs via `yc` CLI and groups them by labels. +""" + +import json +import subprocess +import sys + + +def get_instances(): + """Get running instances from Yandex Cloud.""" + try: + result = subprocess.run( + ["yc", "compute", "instance", "list", "--format", "json"], + capture_output=True, text=True, check=True, + ) + return json.loads(result.stdout) + except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError): + return [] + + +def build_inventory(instances): + """Build Ansible inventory from instances list.""" + inventory = { + "_meta": {"hostvars": {}}, + "all": {"hosts": [], "children": ["webservers"]}, + "webservers": {"hosts": []}, + } + + for inst in instances: + if inst.get("status") != "RUNNING": + continue + + name = inst["name"] + # Get public IP + public_ip = None + for iface in inst.get("network_interfaces", []): + one_to_one = iface.get("primary_v4_address", {}).get("one_to_one_nat", {}) + public_ip = one_to_one.get("address") + if public_ip: + break + + if not public_ip: + continue + + inventory["all"]["hosts"].append(name) + + # Group by label 'project=devops-course' + labels = inst.get("labels", {}) + if labels.get("project") == "devops-course": + inventory["webservers"]["hosts"].append(name) + + inventory["_meta"]["hostvars"][name] = { + "ansible_host": public_ip, + "ansible_user": "ubuntu", + "ansible_ssh_private_key_file": "~/.ssh/id_ed25519", + } + + return inventory + + +def main(): + if len(sys.argv) == 2 and sys.argv[1] == "--list": + instances = get_instances() + inventory = build_inventory(instances) + print(json.dumps(inventory, indent=2)) + elif len(sys.argv) == 2 and sys.argv[1] == "--host": + print(json.dumps({})) + else: + print(json.dumps({"_meta": {"hostvars": {}}})) + + +if __name__ == "__main__": + main() + diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml new file mode 100644 index 0000000000..0cb1e56f19 --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,10 @@ +--- +- name: Deploy application + hosts: webservers + become: true + vars_files: + - ../group_vars/all.yml + + 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..9413dabdd8 --- /dev/null +++ b/ansible/playbooks/deploy_all.yml @@ -0,0 +1,29 @@ +--- +- name: Deploy All Applications + hosts: webservers + become: true + vars_files: + - ../group_vars/all.yml + + tasks: + - name: Deploy Python App + ansible.builtin.include_role: + name: web_app + vars: + app_name: devops-python + docker_image: dmitry567/devops-info-service + docker_tag: latest + app_port: "5000" + app_internal_port: "5000" + compose_project_dir: "/opt/devops-python" + + - name: Deploy Scala App + ansible.builtin.include_role: + name: web_app + vars: + app_name: devops-scala + docker_image: dmitry567/devops-info-service-scala + docker_tag: latest + app_port: "5001" + app_internal_port: "5000" + compose_project_dir: "/opt/devops-scala" diff --git a/ansible/playbooks/deploy_python.yml b/ansible/playbooks/deploy_python.yml new file mode 100644 index 0000000000..11d2e9a3cf --- /dev/null +++ b/ansible/playbooks/deploy_python.yml @@ -0,0 +1,10 @@ +--- +- name: Deploy Python Application + hosts: webservers + become: true + vars_files: + - ../group_vars/all.yml + - ../vars/app_python.yml + + roles: + - web_app diff --git a/ansible/playbooks/deploy_scala.yml b/ansible/playbooks/deploy_scala.yml new file mode 100644 index 0000000000..020e726cb3 --- /dev/null +++ b/ansible/playbooks/deploy_scala.yml @@ -0,0 +1,10 @@ +--- +- name: Deploy Scala Application + hosts: webservers + become: true + vars_files: + - ../group_vars/all.yml + - ../vars/app_scala.yml + + roles: + - web_app diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..362e19a8b2 --- /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..66be6f9962 --- /dev/null +++ b/ansible/playbooks/site.yml @@ -0,0 +1,14 @@ +--- +- name: Full site deployment + hosts: webservers + become: true + vars_files: + - ../group_vars/all.yml + + roles: + - role: common + tags: [common] + - role: docker + tags: [docker] + - role: web_app + tags: [app_deploy] diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..228de1499a --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,15 @@ +--- +common_packages: + - python3-pip + - curl + - git + - vim + - htop + - wget + - unzip + - ca-certificates + - gnupg + - lsb-release + +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..89f5b3b64e --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,47 @@ +--- +- name: Wait for automatic updates to finish + ansible.builtin.shell: while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do sleep 5; done + changed_when: false + +- name: Install system 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: + - name: Fix apt on failure + ansible.builtin.command: apt-get update --fix-missing + changed_when: true + + - name: Retry package install + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + + always: + - name: Log package setup completion + ansible.builtin.copy: + content: "common packages applied {{ ansible_date_time.iso8601 }}\n" + dest: /tmp/ansible_common_done + mode: "0644" + + become: true + tags: + - packages + +- name: Configure users + block: + - name: Set timezone + community.general.timezone: + name: "{{ timezone }}" + + become: true + tags: + - users diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000000..7f30bd8976 --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,9 @@ +--- +docker_user: ubuntu +docker_packages: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000000..cf84b5fde9 --- /dev/null +++ b/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- 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..1e7e6d0c88 --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,62 @@ +--- +- name: Install Docker + block: + - name: Add Docker GPG key + ansible.builtin.apt_key: + url: https://download.docker.com/linux/ubuntu/gpg + state: present + + - name: Add Docker repository + ansible.builtin.apt_repository: + repo: "deb https://download.docker.com/linux/ubuntu {{ ansible_facts['distribution_release'] }} stable" + state: present + filename: docker + + - name: Install Docker packages + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + update_cache: true + notify: restart docker + + - name: Install python3-docker for Ansible modules + ansible.builtin.apt: + name: python3-docker + state: present + + rescue: + - name: Wait before retry + ansible.builtin.pause: + seconds: 10 + + - name: Retry apt update + ansible.builtin.apt: + update_cache: true + + - name: Retry Docker install + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + + always: + - name: Ensure Docker service is enabled and started + ansible.builtin.service: + name: docker + state: started + enabled: true + + become: true + tags: + - docker_install + +- name: Configure Docker + block: + - name: Add user to docker group + ansible.builtin.user: + name: "{{ docker_user }}" + groups: docker + append: true + + become: true + tags: + - docker_config diff --git a/ansible/roles/web_app/defaults/main.yml b/ansible/roles/web_app/defaults/main.yml new file mode 100644 index 0000000000..1d4afd5781 --- /dev/null +++ b/ansible/roles/web_app/defaults/main.yml @@ -0,0 +1,9 @@ +--- +app_name: devops-app +docker_image: dmitry567/devops-info-service +docker_tag: latest +app_port: "5000" +app_internal_port: "5000" +compose_project_dir: "/opt/{{ app_name }}" + +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..1fb4d9cf2f --- /dev/null +++ b/ansible/roles/web_app/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart app + ansible.builtin.command: + cmd: docker compose restart + chdir: "{{ compose_project_dir }}" diff --git a/ansible/roles/web_app/meta/main.yml b/ansible/roles/web_app/meta/main.yml new file mode 100644 index 0000000000..cb7d8e0460 --- /dev/null +++ b/ansible/roles/web_app/meta/main.yml @@ -0,0 +1,3 @@ +--- +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..2e2db89871 --- /dev/null +++ b/ansible/roles/web_app/tasks/main.yml @@ -0,0 +1,58 @@ +--- +- name: Include wipe tasks + ansible.builtin.include_tasks: wipe.yml + tags: + - web_app_wipe + +- name: Deploy application with Docker Compose + block: + - name: Create application directory + ansible.builtin.file: + path: "{{ compose_project_dir }}" + state: directory + mode: "0755" + + - name: Template docker-compose file + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ compose_project_dir }}/docker-compose.yml" + mode: "0644" + + - name: Pull latest image + ansible.builtin.command: + cmd: docker compose pull + chdir: "{{ compose_project_dir }}" + changed_when: true + + - name: Start services with docker compose + ansible.builtin.command: + cmd: docker compose up -d + chdir: "{{ compose_project_dir }}" + register: compose_result + changed_when: true + + - name: Wait for application to be ready + ansible.builtin.wait_for: + port: "{{ app_port }}" + host: 127.0.0.1 + delay: 5 + timeout: 30 + + - name: Verify health endpoint + ansible.builtin.uri: + url: "http://127.0.0.1:{{ app_port }}/health" + return_content: true + register: health_check + + - name: Show health check result + ansible.builtin.debug: + var: health_check.json + + rescue: + - name: Log deployment failure + ansible.builtin.debug: + msg: "Deployment of {{ app_name }} failed — check logs with: docker compose -f {{ compose_project_dir }}/docker-compose.yml logs" + + tags: + - app_deploy + - compose diff --git a/ansible/roles/web_app/tasks/wipe.yml b/ansible/roles/web_app/tasks/wipe.yml new file mode 100644 index 0000000000..6c4c20adca --- /dev/null +++ b/ansible/roles/web_app/tasks/wipe.yml @@ -0,0 +1,26 @@ +--- +- name: Wipe web application + block: + - name: Stop and remove containers + ansible.builtin.command: + cmd: docker compose down --remove-orphans + chdir: "{{ compose_project_dir }}" + ignore_errors: true + + - 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 }} wiped successfully" + + when: web_app_wipe | bool + tags: + - web_app_wipe diff --git a/ansible/roles/web_app/templates/docker-compose.yml.j2 b/ansible/roles/web_app/templates/docker-compose.yml.j2 new file mode 100644 index 0000000000..76b532697a --- /dev/null +++ b/ansible/roles/web_app/templates/docker-compose.yml.j2 @@ -0,0 +1,11 @@ +version: '3.8' + +services: + {{ app_name }}: + image: {{ docker_image }}:{{ docker_tag }} + container_name: {{ app_name }} + ports: + - "{{ app_port }}:{{ app_internal_port }}" + environment: + PORT: "{{ app_internal_port }}" + restart: unless-stopped diff --git a/ansible/vars/app_python.yml b/ansible/vars/app_python.yml new file mode 100644 index 0000000000..455ddd4184 --- /dev/null +++ b/ansible/vars/app_python.yml @@ -0,0 +1,7 @@ +--- +app_name: devops-python +docker_image: dmitry567/devops-info-service +docker_tag: latest +app_port: "5000" +app_internal_port: "5000" +compose_project_dir: "/opt/{{ app_name }}" diff --git a/ansible/vars/app_scala.yml b/ansible/vars/app_scala.yml new file mode 100644 index 0000000000..a143460e7b --- /dev/null +++ b/ansible/vars/app_scala.yml @@ -0,0 +1,7 @@ +--- +app_name: devops-scala +docker_image: dmitry567/devops-info-service-scala +docker_tag: latest +app_port: "5001" +app_internal_port: "5000" +compose_project_dir: "/opt/{{ app_name }}" diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..305bebd9e5 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*.so +venv/ +.venv/ +*.egg-info/ + +# Git +.git/ +.gitignore + +# IDE +.idea/ +.vscode/ +*.swp + +# Docs (not needed at runtime) +docs/ +README.md + +# Tests +tests/ + +# OS +.DS_Store + diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..2183ef852f --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,53 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# Logs +*.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment variables +.env +.env.local + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..333c20c6d8 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.13-slim + +# Create non-root user +RUN useradd --create-home --shell /bin/bash appuser + +WORKDIR /app + +# Copy and install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY app.py . + +# Switch to non-root user +USER appuser + +EXPOSE 5000 + +CMD ["python", "app.py"] + diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..b3637589b5 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,228 @@ +# DevOps Info Service + +![CI](https://github.com/trunn5/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg) +![Coverage](https://codecov.io/gh/trunn5/DevOps-Core-Course/branch/master/graph/badge.svg) +![Ansible Deployment](https://github.com/trunn5/DevOps-Core-Course/actions/workflows/ansible-deploy.yml/badge.svg) + +A Python web service that provides detailed information about itself and its runtime environment. Built as part of the DevOps course curriculum. + +## Overview + +The DevOps Info Service is a modern FastAPI-based web application that exposes system information, runtime metrics, and health status through a REST API. This service serves as a foundation for learning DevOps practices including containerization, CI/CD, monitoring, and Kubernetes deployment. + +## Prerequisites + +- **Python**: 3.11 or higher +- **pip**: Python package manager +- **Virtual environment** (recommended) + +## Installation + +1. **Clone the repository** (if not already done): + ```bash + git clone + cd app_python + ``` + +2. **Create and activate virtual environment**: + ```bash + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. **Install dependencies**: + ```bash + pip install -r requirements.txt + ``` + +## Running the Application + +### Development Mode + +```bash +python app.py +``` + +The service will start on `http://0.0.0.0:5000` by default. + +### Custom Configuration + +```bash +# Custom port +PORT=8080 python app.py + +# Custom host and port +HOST=127.0.0.1 PORT=3000 python app.py + +# Enable debug mode (auto-reload) +DEBUG=true python app.py +``` + +### Using Uvicorn Directly + +```bash +uvicorn app:app --host 0.0.0.0 --port 5000 --reload +``` + +## API Endpoints + +FastAPI provides automatic interactive API documentation at: +- **Swagger UI**: `http://localhost:5000/docs` +- **ReDoc**: `http://localhost:5000/redoc` + +### `GET /` - Service Information + +Returns comprehensive service and system information. + +**Request:** +```bash +curl http://localhost:5000/ +``` + +**Response:** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": { + "hostname": "my-laptop", + "platform": "Darwin", + "platform_version": "macOS-14.0-arm64-arm-64bit", + "architecture": "arm64", + "cpu_count": 8, + "python_version": "3.11.0" + }, + "runtime": { + "uptime_seconds": 3600, + "uptime_human": "1 hour, 0 minutes", + "current_time": "2026-01-28T14:30:00.000000+00:00", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/7.81.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] +} +``` + +### `GET /health` - Health Check + +Returns the health status of the service. Used for monitoring and orchestration (e.g., Kubernetes probes). + +**Request:** +```bash +curl http://localhost:5000/health +``` + +**Response:** +```json +{ + "status": "healthy", + "timestamp": "2026-01-28T14:30:00.000000+00:00", + "uptime_seconds": 3600 +} +``` + +**HTTP Status Codes:** +- `200 OK` - Service is healthy + +## Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `HOST` | `0.0.0.0` | Host address to bind to | +| `PORT` | `5000` | Port number to listen on | +| `DEBUG` | `False` | Enable uvicorn reload mode | + +## Docker + +### Build Image +```bash +docker build -t devops-info-service . +``` + +### Run Container +```bash +docker run -p 5000:5000 devops-info-service +``` + +### Pull from Docker Hub +```bash +docker pull dmitry567/devops-info-service:latest +docker run -p 5000:5000 dmitry567/devops-info-service:latest +``` + +## Project Structure + +``` +app_python/ +├── app.py # Main application +├── requirements.txt # Python dependencies +├── .gitignore # Git ignore rules +├── README.md # This file +├── tests/ # Unit tests +│ └── __init__.py +└── docs/ # Documentation + ├── LAB01.md # Lab submission + └── screenshots/ # Evidence screenshots +``` + +## Testing + +```bash +# Install dev dependencies +pip install -r requirements-dev.txt + +# Run tests +pytest tests/ -v + +# Run tests with coverage +pytest tests/ -v --cov=. --cov-report=term + +# Lint +ruff check . +``` + +## Development + +### Code Style + +This project follows [PEP 8](https://pep8.org/) style guidelines. Key practices: + +- Clear, descriptive function names +- Docstrings for all functions +- Proper import grouping (standard library, third-party, local) +- Async/await for endpoint handlers + +### Logging + +The application uses Python's built-in logging module with INFO level by default: + +``` +2026-01-28 14:30:00,000 - app - INFO - Starting DevOps Info Service on 0.0.0.0:5000 +2026-01-28 14:30:05,123 - app - INFO - Request: GET / from 127.0.0.1 +``` + +## Future Enhancements + +This service will evolve throughout the DevOps course: + +- **Lab 2**: Docker containerization +- **Lab 3**: Unit tests and CI/CD pipeline +- **Lab 8**: Prometheus metrics endpoint (`/metrics`) +- **Lab 9**: Kubernetes deployment with health probes +- **Lab 12**: Visit counter with file persistence (`/visits`) + +## License + +This project is part of the DevOps course curriculum. diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..0c609d6b57 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,161 @@ +""" +DevOps Info Service +Main application module providing system information and health status. +""" +import os +import socket +import platform +import logging +from datetime import datetime, timezone + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +import uvicorn + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +app = FastAPI( + title="DevOps Info Service", + description="DevOps course info service providing system information and health status", + version="1.0.0" +) + +# Configuration from environment variables +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', 5000)) +DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' + +# Application start time for uptime calculation +START_TIME = datetime.now(timezone.utc) + +# Service metadata +SERVICE_INFO = { + 'name': 'devops-info-service', + 'version': '1.0.0', + 'description': 'DevOps course info service', + 'framework': 'FastAPI' +} + + +def get_uptime(): + """Calculate application uptime since start.""" + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + remaining_seconds = seconds % 60 + + # Build human-readable string + parts = [] + if hours > 0: + parts.append(f"{hours} hour{'s' if hours != 1 else ''}") + if minutes > 0: + parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") + if remaining_seconds > 0 or not parts: + parts.append(f"{remaining_seconds} second{'s' if remaining_seconds != 1 else ''}") + + return { + 'seconds': seconds, + 'human': ', '.join(parts) + } + + +def get_system_info(): + """Collect system information.""" + return { + 'hostname': socket.gethostname(), + 'platform': platform.system(), + 'platform_version': platform.platform(), + 'architecture': platform.machine(), + 'cpu_count': os.cpu_count(), + 'python_version': platform.python_version() + } + + +def get_request_info(request: Request): + """Extract request information.""" + return { + 'client_ip': request.client.host if request.client else 'unknown', + 'user_agent': request.headers.get('user-agent', 'Unknown'), + 'method': request.method, + 'path': request.url.path + } + + +def get_endpoints(): + """List available API endpoints.""" + return [ + {'path': '/', 'method': 'GET', 'description': 'Service information'}, + {'path': '/health', 'method': 'GET', 'description': 'Health check'} + ] + + +@app.get('/') +async def index(request: Request): + """Main endpoint - returns service and system information.""" + logger.info(f'Request: {request.method} {request.url.path} from {request.client.host if request.client else "unknown"}') + + uptime = get_uptime() + + return { + 'service': SERVICE_INFO, + 'system': get_system_info(), + 'runtime': { + 'uptime_seconds': uptime['seconds'], + 'uptime_human': uptime['human'], + 'current_time': datetime.now(timezone.utc).isoformat(), + 'timezone': 'UTC' + }, + 'request': get_request_info(request), + 'endpoints': get_endpoints() + } + + +@app.get('/health') +async def health(): + """Health check endpoint for monitoring and orchestration.""" + logger.debug('Health check requested') + + return { + 'status': 'healthy', + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'uptime_seconds': get_uptime()['seconds'] + } + + +@app.exception_handler(404) +async def not_found_handler(request: Request, exc): + """Handle 404 Not Found errors.""" + logger.warning(f'404 Not Found: {request.url.path}') + return JSONResponse( + status_code=404, + content={ + 'error': 'Not Found', + 'message': 'Endpoint does not exist', + 'path': request.url.path + } + ) + + +@app.exception_handler(500) +async def internal_error_handler(request: Request, exc): + """Handle 500 Internal Server errors.""" + logger.error(f'500 Internal Server Error: {exc}') + return JSONResponse( + status_code=500, + content={ + 'error': 'Internal Server Error', + 'message': 'An unexpected error occurred' + } + ) + + +if __name__ == '__main__': + logger.info(f'Starting DevOps Info Service on {HOST}:{PORT}') + logger.info(f'Debug mode: {DEBUG}') + uvicorn.run(app, host=HOST, port=PORT, reload=DEBUG) diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..eec728c7bb --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,58 @@ +# Lab 01 - DevOps Info Service + +## Framework Choice: FastAPI + +| Criteria | Flask | FastAPI | Django | +|----------|-------|---------|--------| +| Auto API Docs | ❌ | ✅ | ❌ | +| Async Support | ❌ | ✅ | ❌ | +| Performance | Good | Excellent | Good | +| Learning Curve | Easy | Easy | Hard | + +**Why FastAPI:** Auto-generated Swagger docs (`/docs`), native async, type safety, high performance. + +--- + +## Best Practices + +1. **Environment Config** - `HOST`, `PORT`, `DEBUG` via `os.getenv()` +2. **Logging** - Structured logging with timestamps +3. **Error Handling** - Custom 404/500 JSON responses +4. **Clean Code** - PEP 8, docstrings, separated functions + +--- + +## API Endpoints + +### `GET /` +```bash +curl http://localhost:5000/ +``` +Returns: service info, system info, runtime, request details, endpoints list. + +### `GET /health` +```bash +curl http://localhost:5000/health +``` +Returns: `{"status": "healthy", "timestamp": "...", "uptime_seconds": 123}` + +### Auto Docs +- Swagger: `http://localhost:5000/docs` +- ReDoc: `http://localhost:5000/redoc` + +--- + +## Screenshots + +1. `screenshots/01-main-endpoint.png` - Main endpoint response +2. `screenshots/02-health-check.png` - Health check response +3. `screenshots/03-formatted-output.png` - Pretty JSON / Swagger UI + +--- + +## Challenges + +| Problem | Solution | +|---------|----------| +| Timezone errors | Use `datetime.now(timezone.utc)` everywhere | +| Client IP access | `request.client.host` with null check | diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..2340583d6b --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,80 @@ +# Lab 02 - Docker Containerization + +## Best Practices Applied + +| Practice | Why It Matters | +|----------|----------------------------------------------------------------------------| +| **Non-root user** | Security - limits damage if container has hacked | +| **python:3.13-slim** | Smaller image (~200MB vs ~1GB full) | +| **Layer ordering** | Cache optimization - doesn't redownload the dependecies after code changed | +| **.dockerignore** | Keep only necessary, faster builds, smaller context, no secrets leaked | + +### Dockerfile Breakdown + +```dockerfile +# Specific version, slim variant +FROM python:3.13-slim + +# Security: non-root user +RUN useradd --create-home --shell /bin/bash appuser + +WORKDIR /app + +# Layer caching: dependencies first (rarely change) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Application code (changes frequently) +COPY app.py . + +# Switch to non-root +USER appuser + +EXPOSE 5000 +CMD ["python", "app.py"] +``` + +--- + +## Image Information + +- **Base image**: `python:3.13-slim` — minimal Debian with Python, no extra packages +- **Final size**: ~267MB +- **Layers**: 7 (base → user → workdir → deps → code → user switch → cmd) + +--- + +## Build & Run + +```bash +# Build +docker build -t devops-info-service app_python + +# Run +docker run -p 5000:5000 devops-info-service + +# Test +curl http://localhost:5000/ +curl http://localhost:5000/health +``` + +--- + +## Docker Hub + +```bash +docker tag devops-info-service dmitry567/devops-info-service:1.0.0 +docker push dmitry567/devops-info-service:1.0.0 +``` + +**Repository**: `https://hub.docker.com/r/dmitry567/devops-info-service` + +--- + +## Challenges + +| Problem | Solution | +|---------|----------| +| Large image size | Used `slim` variant instead of full Python image | +| Slow rebuilds | Moved `COPY requirements.txt` before `COPY app.py` | + diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..c26e3b4f61 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,69 @@ +# Lab 03 - CI/CD with GitHub Actions + +## Overview + +- **Testing**: pytest + httpx (FastAPI test client) +- **Linting**: ruff +- **Versioning**: CalVer (`YYYY.MM.sha`) +- **Triggers**: push to main/master/lab03, PRs to main/master (path-filtered to `app_python/`) + +--- + +## Workflow Structure + +``` +python-ci.yml +├── test → Install deps, lint (ruff), run tests (pytest) +├── security → Snyk vulnerability scan (needs: test) +└── docker → Build & push to Docker Hub (needs: test, push only) +``` + +--- + +## Versioning: CalVer + +| Tag | Example | +|-----|---------| +| `YYYY.MM.sha` | `2026.02.a1b2c3d` | +| `YYYY.MM` | `2026.02` | +| `latest` | always latest push | + +**Why CalVer:** This is a service, not a library. Date-based versions show when it was deployed. + +--- + +## Best Practices + +| Practice | Why | +|----------|-----| +| **Dependency caching** | `actions/setup-python` caches pip — faster CI runs | +| **Path filters** | Only runs when `app_python/` changes | +| **Job dependencies** | Docker push only after tests pass | +| **Conditional push** | Only push images on `push`, not on PRs | +| **Snyk scanning** | Catches vulnerable dependencies | +| **Status badge** | Shows CI health in README | +| **Docker layer cache** | Reuses layers from registry for faster builds | + +--- + +## Snyk + +- Severity threshold: `high` +- `continue-on-error: true` — warns but doesn't block deploy + +--- + +## Key Decisions + +- **pytest over unittest**: simpler syntax, fixtures, plugins +- **ruff over flake8/pylint**: fast, all-in-one linter +- **CalVer over SemVer**: continuous deployment model, date = release time +- **Path filters**: monorepo — don't rebuild Python when only docs change + +--- + +## Workflow Evidence + +- Workflow: `.github/workflows/python-ci.yml` +- Docker Hub: `https://hub.docker.com/r/dmitry567/devops-info-service` + diff --git a/app_python/docs/screenshots/01-main-endpoint.png b/app_python/docs/screenshots/01-main-endpoint.png new file mode 100644 index 0000000000..ae0ba74c0e Binary files /dev/null and b/app_python/docs/screenshots/01-main-endpoint.png differ diff --git a/app_python/docs/screenshots/02-health-check.png b/app_python/docs/screenshots/02-health-check.png new file mode 100644 index 0000000000..fc3e81770f Binary files /dev/null and b/app_python/docs/screenshots/02-health-check.png differ diff --git a/app_python/docs/screenshots/03-formatted-output.png b/app_python/docs/screenshots/03-formatted-output.png new file mode 100644 index 0000000000..626549a578 Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/app_python/docs/screenshots/04-push-to-docker-hub.png b/app_python/docs/screenshots/04-push-to-docker-hub.png new file mode 100644 index 0000000000..7a0e7ca0c5 Binary files /dev/null and b/app_python/docs/screenshots/04-push-to-docker-hub.png differ diff --git a/app_python/docs/screenshots/05-docker-build.png b/app_python/docs/screenshots/05-docker-build.png new file mode 100644 index 0000000000..4cc8951feb Binary files /dev/null and b/app_python/docs/screenshots/05-docker-build.png differ diff --git a/app_python/docs/screenshots/06-docker-run.png b/app_python/docs/screenshots/06-docker-run.png new file mode 100644 index 0000000000..c901c51704 Binary files /dev/null and b/app_python/docs/screenshots/06-docker-run.png differ diff --git a/app_python/docs/screenshots/07-docker-testing.png b/app_python/docs/screenshots/07-docker-testing.png new file mode 100644 index 0000000000..3430b6dc66 Binary files /dev/null and b/app_python/docs/screenshots/07-docker-testing.png differ diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..70ba6398b6 --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,8 @@ +# Testing +pytest==8.3.4 +httpx==0.27.0 +pytest-cov==6.0.0 + +# Linting +ruff==0.8.6 + diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..46f9a90722 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,3 @@ +# Web Framework +fastapi==0.115.0 +uvicorn[standard]==0.32.0 diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..4a0fa717bc --- /dev/null +++ b/app_python/tests/__init__.py @@ -0,0 +1,2 @@ +# Tests package for DevOps Info Service + diff --git a/app_python/tests/test_app.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..87700294d0 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,159 @@ +"""Unit tests for DevOps Info Service.""" +import pytest +from fastapi.testclient import TestClient + +from app import app, get_uptime, get_system_info, get_endpoints, SERVICE_INFO + + +@pytest.fixture +def client(): + """Create a test client for the FastAPI app.""" + return TestClient(app) + + +# --- GET / tests --- + +class TestIndexEndpoint: + """Tests for the main endpoint.""" + + def test_index_returns_200(self, client): + response = client.get("/") + assert response.status_code == 200 + + def test_index_returns_json(self, client): + response = client.get("/") + assert response.headers["content-type"] == "application/json" + + def test_index_has_all_sections(self, client): + data = client.get("/").json() + assert "service" in data + assert "system" in data + assert "runtime" in data + assert "request" in data + assert "endpoints" in data + + def test_index_service_fields(self, client): + data = client.get("/").json()["service"] + assert data["name"] == "devops-info-service" + assert data["version"] == "1.0.0" + assert data["framework"] == "FastAPI" + assert "description" in data + + def test_index_system_fields(self, client): + data = client.get("/").json()["system"] + for field in ["hostname", "platform", "platform_version", + "architecture", "cpu_count", "python_version"]: + assert field in data + assert isinstance(data["cpu_count"], int) + assert data["cpu_count"] > 0 + + def test_index_runtime_fields(self, client): + data = client.get("/").json()["runtime"] + assert "uptime_seconds" in data + assert "uptime_human" in data + assert "current_time" in data + assert data["timezone"] == "UTC" + assert isinstance(data["uptime_seconds"], int) + assert data["uptime_seconds"] >= 0 + + def test_index_request_fields(self, client): + data = client.get("/").json()["request"] + assert data["method"] == "GET" + assert data["path"] == "/" + assert "client_ip" in data + assert "user_agent" in data + + def test_index_request_user_agent(self, client): + response = client.get("/", headers={"User-Agent": "test-agent/1.0"}) + data = response.json()["request"] + assert data["user_agent"] == "test-agent/1.0" + + def test_index_endpoints_list(self, client): + data = client.get("/").json()["endpoints"] + assert isinstance(data, list) + assert len(data) >= 2 + paths = [e["path"] for e in data] + assert "/" in paths + assert "/health" in paths + + +# --- GET /health tests --- + +class TestHealthEndpoint: + """Tests for the health check endpoint.""" + + def test_health_returns_200(self, client): + response = client.get("/health") + assert response.status_code == 200 + + def test_health_status_healthy(self, client): + data = client.get("/health").json() + assert data["status"] == "healthy" + + def test_health_has_timestamp(self, client): + data = client.get("/health").json() + assert "timestamp" in data + assert len(data["timestamp"]) > 0 + + def test_health_has_uptime(self, client): + data = client.get("/health").json() + assert "uptime_seconds" in data + assert isinstance(data["uptime_seconds"], int) + assert data["uptime_seconds"] >= 0 + + +# --- Error handling tests --- + +class TestErrorHandling: + """Tests for error handlers.""" + + def test_404_returns_json(self, client): + response = client.get("/nonexistent") + assert response.status_code == 404 + data = response.json() + assert data["error"] == "Not Found" + assert "path" in data + + def test_404_shows_path(self, client): + response = client.get("/some/bad/path") + data = response.json() + assert data["path"] == "/some/bad/path" + + +# --- Helper function tests --- + +class TestHelperFunctions: + """Tests for utility functions.""" + + def test_get_uptime_returns_dict(self): + result = get_uptime() + assert "seconds" in result + assert "human" in result + + def test_get_uptime_seconds_non_negative(self): + result = get_uptime() + assert result["seconds"] >= 0 + + def test_get_uptime_human_is_string(self): + result = get_uptime() + assert isinstance(result["human"], str) + assert len(result["human"]) > 0 + + def test_get_system_info_fields(self): + info = get_system_info() + for field in ["hostname", "platform", "platform_version", + "architecture", "cpu_count", "python_version"]: + assert field in info + + def test_get_endpoints_returns_list(self): + endpoints = get_endpoints() + assert isinstance(endpoints, list) + for ep in endpoints: + assert "path" in ep + assert "method" in ep + assert "description" in ep + + def test_service_info_constant(self): + assert SERVICE_INFO["name"] == "devops-info-service" + assert SERVICE_INFO["framework"] == "FastAPI" + diff --git a/docs/LAB04.md b/docs/LAB04.md new file mode 100644 index 0000000000..bc3ab4f728 --- /dev/null +++ b/docs/LAB04.md @@ -0,0 +1,157 @@ +# Lab 04 — Infrastructure as Code + +## Cloud Provider + +- **Provider**: Yandex Cloud +- **Region**: ru-central1-a +- **Instance**: standard-v2, 2 vCPU (20%), 2 GB RAM, 10 GB HDD +- **OS**: Ubuntu 24.04 LTS +- **Cost**: Free tier + +--- + +## Terraform + +### Structure + +``` +terraform/ +├── main.tf # Provider, network, security group, VM +├── variables.tf # Input variables +├── outputs.tf # Public IP, SSH command +├── key.json # Service account key (gitignored) +├── terraform.tfvars # Variable values (gitignored) +└── .gitignore # State, credentials excluded +``` + +### Resources Created + +| Resource | Description | +|----------|-------------| +| `yandex_vpc_network` | Virtual network | +| `yandex_vpc_subnet` | Subnet 10.0.1.0/24 | +| `yandex_vpc_security_group` | Ports: 22, 80, 5000 | +| `yandex_compute_instance` | Ubuntu 24.04 VM | + +### Commands + +```bash +cd terraform +terraform init # Installed yandex-cloud/yandex v0.187.0 +terraform plan # Plan: 4 to add +terraform apply # Apply complete! Resources: 4 added +# Output: ssh ubuntu@93.77.191.135 +terraform destroy # Destroy complete! Resources: 4 destroyed +``` + +### SSH Access (Terraform VM) + +``` +$ ssh ubuntu@93.77.191.135 "hostname; uname -a" +fhmvhf3cbv2brajc7ekg +Linux fhmvhf3cbv2brajc7ekg 6.8.0-100-generic ... x86_64 GNU/Linux +``` + +--- + +## Pulumi + +### Structure + +``` +pulumi/ +├── __main__.py # All resources in Python +├── Pulumi.yaml # Project config +├── requirements.txt # pulumi + pulumi-yandex +└── .gitignore +``` + +### Same resources, different syntax — Python instead of HCL. + +### Commands + +```bash +cd pulumi +python3 -m venv venv && source venv/bin/activate +pip install -r requirements.txt +export PULUMI_BACKEND_URL="file://." +export PULUMI_CONFIG_PASSPHRASE="" +pulumi stack init dev +pulumi config set ssh_public_key "$(cat ~/.ssh/id_ed25519.pub)" +pulumi config set yandex:cloud_id +pulumi config set yandex:folder_id +pulumi config set yandex:zone ru-central1-a +pulumi config set yandex:service_account_key_file /path/to/key.json +pulumi preview # Resources: + 5 to create +pulumi up --yes # Resources: + 5 created, Duration: 57s +# Output: ssh ubuntu@46.21.246.9 +``` + +### SSH Access (Pulumi VM) + +``` +$ ssh ubuntu@46.21.246.9 "hostname; uname -a" +fhm69v5kuvur1rsvdbr8 +Linux fhm69v5kuvur1rsvdbr8 6.8.0-100-generic ... x86_64 GNU/Linux +``` + +--- + +## Terraform vs Pulumi + +| Aspect | Terraform | Pulumi | +|--------|-----------|--------| +| **Язык** | HCL (декларативный) | Python (императивный) | +| **Читаемость** | Проще для инфры | Привычнее для разработчиков | +| **Логика** | Ограничена (count, for_each) | Полный язык (циклы, функции) | +| **State** | Локальный файл | Pulumi Cloud / локальный | +| **IDE** | Базовая поддержка | Автодополнение, типы | +| **Отладка** | terraform plan | print() + pulumi preview | + +**Terraform лучше:** простая инфра, большая команда, много документации. +**Pulumi лучше:** сложная логика, разработчики в команде, тесты инфры. + +--- + +## Bonus: IaC CI/CD + GitHub Import + +### Part 1: Terraform CI Workflow + +File: `.github/workflows/terraform-ci.yml` + +Triggers on `terraform/**` changes (push + PR): +1. `terraform fmt -check` — formatting +2. `terraform init -backend=false` — init without state +3. `terraform validate` — syntax check +4. `tflint` — linting for best practices + +### Part 2: GitHub Repository Import + +Added `github.tf` — manages `Trunn5/DevOps-Core-Course` repo via Terraform. + +``` +$ terraform import github_repository.course_repo DevOps-Core-Course +github_repository.course_repo: Import prepared! +github_repository.course_repo: Refreshing state... +Import successful! + +$ terraform plan +Plan: 4 to add, 0 to change, 0 to destroy. +# GitHub repo: no changes (state matches reality) +# 4 to add = Yandex Cloud resources (destroyed earlier, Pulumi VM is active) +``` + +**Why importing matters:** +- Version control for infrastructure changes +- Code review before any modification +- Audit trail (who changed what) +- Disaster recovery — recreate from code +- No manual "tribal knowledge" needed + +--- + +## Lab 5 Preparation + +- Pulumi VM оставлена запущенной для Lab 5 (Ansible) +- Подключение: `ssh ubuntu@46.21.246.9` +- Terraform ресурсы уничтожены (`terraform destroy`) diff --git a/pulumi/.gitignore b/pulumi/.gitignore new file mode 100644 index 0000000000..4c5600d3ee --- /dev/null +++ b/pulumi/.gitignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +Pulumi.dev.yaml +Pulumi.*.yaml +!Pulumi.yaml +.pulumi/ + diff --git a/pulumi/Pulumi.yaml b/pulumi/Pulumi.yaml new file mode 100644 index 0000000000..f77d4f52ea --- /dev/null +++ b/pulumi/Pulumi.yaml @@ -0,0 +1,7 @@ +name: devops-infra +runtime: + name: python + options: + virtualenv: venv +description: DevOps course VM infrastructure (Yandex Cloud) + diff --git a/pulumi/__main__.py b/pulumi/__main__.py new file mode 100644 index 0000000000..2cd7f94573 --- /dev/null +++ b/pulumi/__main__.py @@ -0,0 +1,54 @@ +"""DevOps Info Service — Yandex Cloud VM with Pulumi.""" +import pulumi +import pulumi_yandex as yandex + +config = pulumi.Config() +zone = config.get("zone") or "ru-central1-a" +ssh_user = config.get("ssh_user") or "ubuntu" +ssh_public_key = config.require("ssh_public_key") + +# Network +network = yandex.VpcNetwork("devops-network") + +subnet = yandex.VpcSubnet("devops-subnet", + zone=zone, + network_id=network.id, + v4_cidr_blocks=["10.0.1.0/24"]) + +# Security group +security_group = yandex.VpcSecurityGroup("devops-sg", + network_id=network.id, + ingresses=[ + {"description": "SSH", "protocol": "TCP", "port": 22, "v4_cidr_blocks": ["0.0.0.0/0"]}, + {"description": "HTTP", "protocol": "TCP", "port": 80, "v4_cidr_blocks": ["0.0.0.0/0"]}, + {"description": "App", "protocol": "TCP", "port": 5000, "v4_cidr_blocks": ["0.0.0.0/0"]}, + ], + egresses=[ + {"description": "Allow all outbound", "protocol": "ANY", "v4_cidr_blocks": ["0.0.0.0/0"]}, + ]) + +# Get latest Ubuntu image +image = yandex.get_compute_image(family="ubuntu-2404-lts") + +# VM instance +instance = yandex.ComputeInstance("devops-vm", + zone=zone, + platform_id="standard-v2", + resources={"cores": 2, "memory": 2, "core_fraction": 20}, + boot_disk={"initialize_params": {"image_id": image.id, "size": 10, "type": "network-hdd"}}, + network_interfaces=[{ + "subnet_id": subnet.id, + "nat": True, + "security_group_ids": [security_group.id], + }], + metadata={ + "ssh-keys": f"{ssh_user}:{ssh_public_key}", + }, + labels={"project": "devops-course", "lab": "lab04"}) + +# Outputs +pulumi.export("vm_public_ip", instance.network_interfaces[0].nat_ip_address) +pulumi.export("vm_name", instance.name) +pulumi.export("ssh_command", instance.network_interfaces[0].nat_ip_address.apply( + lambda ip: f"ssh {ssh_user}@{ip}")) + diff --git a/pulumi/requirements.txt b/pulumi/requirements.txt new file mode 100644 index 0000000000..278c715e8c --- /dev/null +++ b/pulumi/requirements.txt @@ -0,0 +1,3 @@ +pulumi>=3.0.0 +pulumi-yandex>=0.13.0 + diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000000..3bb53960c4 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,15 @@ +# Terraform state +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl + +# Variables with secrets +terraform.tfvars +*.tfvars + +# Credentials +*.pem +*.key +key.json + diff --git a/terraform/github.tf b/terraform/github.tf new file mode 100644 index 0000000000..1eeddfaa70 --- /dev/null +++ b/terraform/github.tf @@ -0,0 +1,23 @@ +# GitHub provider is configured in main.tf required_providers block + +provider "github" { + token = var.github_token + owner = "Trunn5" +} + +resource "github_repository" "course_repo" { + name = "DevOps-Core-Course" + description = "\U0001f680Production-grade DevOps course: 18 hands-on labs covering Docker, Kubernetes, Helm, Terraform, Ansible, CI/CD, GitOps (ArgoCD), monitoring (Prometheus/Grafana), and more. Build real-world skills with progressive delivery, secrets management, and cloud-native deployments." + visibility = "public" + + has_issues = false + has_wiki = true + has_projects = true + has_downloads = true + + allow_merge_commit = true + allow_squash_merge = true + allow_rebase_merge = true + delete_branch_on_merge = false + archived = false +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..3b1418fceb --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,107 @@ +terraform { + required_providers { + yandex = { + source = "yandex-cloud/yandex" + version = "~> 0.100" + } + github = { + source = "integrations/github" + version = "~> 5.0" + } + } + required_version = ">= 1.5.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" "main" { + name = "devops-network" +} + +resource "yandex_vpc_subnet" "main" { + name = "devops-subnet" + zone = var.zone + network_id = yandex_vpc_network.main.id + v4_cidr_blocks = ["10.0.1.0/24"] +} + +# Security group +resource "yandex_vpc_security_group" "main" { + name = "devops-sg" + network_id = yandex_vpc_network.main.id + + ingress { + description = "SSH" + protocol = "TCP" + port = 22 + v4_cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "HTTP" + protocol = "TCP" + port = 80 + v4_cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "App" + protocol = "TCP" + port = 5000 + v4_cidr_blocks = ["0.0.0.0/0"] + } + + egress { + description = "Allow all outbound" + protocol = "ANY" + v4_cidr_blocks = ["0.0.0.0/0"] + } +} + +# Get latest Ubuntu image +data "yandex_compute_image" "ubuntu" { + family = "ubuntu-2404-lts" +} + +# VM instance +resource "yandex_compute_instance" "devops_vm" { + name = "devops-vm" + platform_id = "standard-v2" + zone = var.zone + + resources { + cores = 2 + memory = 2 + core_fraction = 20 + } + + boot_disk { + initialize_params { + image_id = data.yandex_compute_image.ubuntu.id + size = 10 + type = "network-hdd" + } + } + + network_interface { + subnet_id = yandex_vpc_subnet.main.id + nat = true + security_group_ids = [yandex_vpc_security_group.main.id] + } + + metadata = { + ssh-keys = "${var.ssh_user}:${file(var.ssh_public_key_path)}" + } + + labels = { + project = "devops-course" + lab = "lab04" + } +} + diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000000..40e348d6c4 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,15 @@ +output "vm_public_ip" { + description = "Public IP of the VM" + value = yandex_compute_instance.devops_vm.network_interface[0].nat_ip_address +} + +output "vm_name" { + description = "VM instance name" + value = yandex_compute_instance.devops_vm.name +} + +output "ssh_command" { + description = "SSH connection command" + value = "ssh ${var.ssh_user}@${yandex_compute_instance.devops_vm.network_interface[0].nat_ip_address}" +} + diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..72e3fa675b --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,41 @@ +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 = "key.json" +} + +variable "ssh_user" { + description = "SSH username for the VM" + type = string + default = "ubuntu" +} + +variable "ssh_public_key_path" { + description = "Path to SSH public key" + type = string + default = "~/.ssh/id_rsa.pub" +} + +variable "github_token" { + description = "GitHub Personal Access Token" + type = string + sensitive = true + default = "" +} +