diff --git a/.github/workflows/ansible-deploy.yml b/.github/workflows/ansible-deploy.yml new file mode 100644 index 0000000000..b265fd16f0 --- /dev/null +++ b/.github/workflows/ansible-deploy.yml @@ -0,0 +1,74 @@ +name: Ansible Deployment + +on: + push: + # branches: [main, master] + paths: + - 'ansible/**' + - '!ansible/docs/**' + - '.github/workflows/ansible-deploy.yml' + pull_request: + branches: [main, master] + paths: + - 'ansible/**' + +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 + + - name: Run ansible-lint + run: | + cd ansible + ansible-lint playbooks/*.yml + + deploy: + name: Deploy Application + needs: lint + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + 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 + run: pip install ansible + + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H ${{ secrets.VM_HOST }} >> ~/.ssh/known_hosts + + - name: Deploy with Ansible + env: + ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }} + run: | + cd ansible + echo "$ANSIBLE_VAULT_PASSWORD" > /tmp/vault_pass + ansible-playbook playbooks/deploy.yml \ + -i inventory/hosts.ini \ + --vault-password-file /tmp/vault_pass + rm /tmp/vault_pass + + - name: Verify Deployment + run: | + sleep 10 + curl -f http://${{ secrets.VM_HOST }}:5000 || exit 1 diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..9a03459e59 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,104 @@ +name: Python app - Test & Docker Push + +on: + push: + pull_request: + types: [opened] + +defaults: + run: + working-directory: + ./app_python + +env: + DOCKER_HUB_USERNAME: ${{ vars.DOCKER_HUB_USERNAME }} + IMAGE_NAME: ${{ vars.IMAGE_NAME }} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + code-quality-and-testing: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ '3.11', '3.12', '3.13' ] + fail-fast: true + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python ${{matrix.python-version}} + uses: actions/setup-python@v4 + with: + python-version: ${{matrix.python-version}} + cache: 'pip' + + - name: Cache virtual environment + id: cache-venv + uses: actions/cache@v4 + with: + path: ./app_python/venv + key: venv-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('./app_python/requirements.txt') }} + + - name: Create virtual environment and install dependencies + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + python -m venv venv + . venv/bin/activate + pip install -r requirements.txt + + - name: Run flake8 + run: venv/bin/flake8 ./app.py ./tests/test_app.py + + - name: Run tests + run: venv/bin/pytest + + - name: Install Snyk + uses: snyk/actions/setup@master + + - name: Run Snyk + run: | + . venv/bin/activate + snyk test --severity-threshold=high --file=requirements.txt + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + continue-on-error: false + timeout-minutes: 2 + + docker-build-push: + runs-on: ubuntu-latest + needs: code-quality-and-testing + if: github.event_name == 'pull_request' && github.event.action == 'opened' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Docker tags + id: tags + uses: docker/metadata-action@v5 + with: + images: ${{env.DOCKER_HUB_USERNAME}}/${{env.IMAGE_NAME}} + tags: | + type=semver,pattern={{version}} + type=raw,value=latest + - name: Log in to docker hub + uses: docker/login-action@v3 + with: + username: ${{ env.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./app_python + push: true + tags: ${{ steps.tags.outputs.tags }} + labels: ${{ steps.tags.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 30d74d2584..dce30f2270 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,31 @@ -test \ No newline at end of file +test +.pytest_cache/ + +# Terraform +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl +terraform.tfvars +*.tfvars +crash.log + +# Pulumi +pulumi/venv/ +pulumi/__pycache__/ +pulumi/*.pyc +Pulumi.*.yaml + +# Cloud credentials +*.pem +*.key +service-account-key.json +credentials.json + +# Ansible +*.retry +.vault_pass +ansible/inventory/*.pyc +__pycache__/ + +.env \ No newline at end of file diff --git a/README.md b/README.md index 371d51f456..75bcc0786a 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![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/AidarSarvartdinov/DevOps-Core-Course/actions/workflows/ansible-deploy.yml/badge.svg)](https://github.com/AidarSarvartdinov/DevOps-Core-Course/actions/workflows/ansible-deploy.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/Dockerfile b/ansible/Dockerfile new file mode 100644 index 0000000000..1ad70244c7 --- /dev/null +++ b/ansible/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + openssh-client \ + sshpass \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir \ + ansible \ + docker + +RUN mkdir -p /root/.ssh + +WORKDIR /ansible + +# Copy ansible.cfg to a safe (non-world-writable) location +COPY ansible.cfg /etc/ansible/ansible.cfg +ENV ANSIBLE_CONFIG=/etc/ansible/ansible.cfg + +# Entrypoint fixes SSH key permissions (Windows → Linux) +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..5047d1bcd7 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,11 @@ +[defaults] +inventory = /ansible/inventory/hosts.ini +roles_path = /ansible/roles +host_key_checking = False +remote_user = ubuntu +retry_files_enabled = False + +[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..b5c721124c --- /dev/null +++ b/ansible/docs/LAB05.md @@ -0,0 +1,300 @@ +# Lab 5 — Ansible Fundamentals + +## 1. Architecture Overview + +- **Ansible version:** latest (installed via pip inside Docker container) +- **Control node:** Docker container (`python:3.11-slim` + Ansible), running on Windows +- **Target VM:** Ubuntu 24.04 LTS on Yandex Cloud (provisioned via Pulumi) +- **VM IP:** `89.169.137.6`, SSH user: `ubuntu` + +### Role Structure + +``` +ansible/ +├── ansible.cfg +├── Dockerfile # Ansible control node (Docker on Windows) +├── inventory/ +│ └── hosts.ini # Static inventory (Yandex Cloud VM) +├── roles/ +│ ├── common/ # System baseline: packages, timezone +│ │ ├── tasks/main.yml +│ │ └── defaults/main.yml +│ ├── docker/ # Docker CE installation +│ │ ├── tasks/main.yml +│ │ ├── handlers/main.yml +│ │ └── defaults/main.yml +│ └── app_deploy/ # Application container deployment +│ ├── tasks/main.yml +│ ├── handlers/main.yml +│ └── defaults/main.yml +├── playbooks/ +│ ├── site.yml # Full: provision + deploy +│ ├── provision.yml # System provisioning only +│ └── deploy.yml # App deployment only +├── group_vars/ +│ └── all.yml # Encrypted variables (Ansible Vault) +└── docs/ + └── LAB05.md # This file +``` + +### Why Roles Instead of Monolithic Playbooks? + +Roles provide **modular, reusable, and testable** units of automation. Each role encapsulates a single responsibility (e.g., Docker installation). This makes it easy to reuse the `docker` role in other projects, test each role independently, and maintain clear separation of concerns. + +--- + +## 2. Roles Documentation + +### Common Role (`roles/common/`) + +| Item | Details | +|------|---------| +| **Purpose** | Install essential system packages, set timezone | +| **Key Variables** | `common_packages` (list of apt packages), `timezone` (default: UTC) | +| **Handlers** | None | +| **Dependencies** | None | + +### Docker Role (`roles/docker/`) + +| Item | Details | +|------|---------| +| **Purpose** | Install Docker CE from official repository | +| **Key Variables** | `docker_users` (users to add to docker group), `docker_packages` (Docker package list) | +| **Handlers** | `restart docker` — restarts Docker service when package is installed or config changes | +| **Dependencies** | None (prerequisites installed within the role) | + +### App Deploy Role (`roles/app_deploy/`) + +| Item | Details | +|------|---------| +| **Purpose** | Pull and run containerized Python app from Docker Hub | +| **Key Variables** | `dockerhub_username`, `dockerhub_password` (from Vault), `docker_image`, `docker_image_tag`, `app_port`, `app_container_name`, `app_restart_policy` | +| **Handlers** | `restart app container` — restarts the application container | +| **Dependencies** | Requires Docker to be installed (docker role) | + +--- + +## 3. Idempotency Demonstration + +### First Run + +``` +PLAY [Provision web servers] ****************************************************************************************** + +TASK [Gathering Facts] ********************************************************************************************** +ok: [yc-vm] + +TASK [common : Update apt cache] ************************************************************************************** +changed: [yc-vm] + +TASK [common : Install common packages] *******************************************************************************changed: [yc-vm] + +TASK [common : Set timezone] ******************************************************************************************changed: [yc-vm] + +TASK [docker : Install Docker prerequisites] **************************************************************************ok: [yc-vm] + +TASK [docker : Create keyrings directory] *****************************************************************************ok: [yc-vm] + +TASK [docker : Add Docker GPG key] ************************************************************************************changed: [yc-vm] + +TASK [docker : Add Docker repository] *********************************************************************************changed: [yc-vm] + +TASK [docker : Install Docker packages] *******************************************************************************changed: [yc-vm] + +TASK [docker : Ensure Docker service is running and enabled] **********************************************************ok: [yc-vm] + +TASK [docker : Add users to docker group] *****************************************************************************changed: [yc-vm] => (item=ubuntu) + +TASK [docker : Install python3-docker for Ansible modules] ************************************************************changed: [yc-vm] + +RUNNING HANDLER [docker : restart docker] *****************************************************************************changed: [yc-vm] + +PLAY RECAP ************************************************************************************************************yc-vm : ok=13 changed=9 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + +### Second Run + +``` +PLAY [Provision web servers] ****************************************************************************************** +TASK [Gathering Facts] ********************************************************************************************** +ok: [yc-vm] + +TASK [common : Update apt cache] **************************************************************************************ok: [yc-vm] + +TASK [common : Install common packages] *******************************************************************************ok: [yc-vm] + +TASK [common : Set timezone] ******************************************************************************************ok: [yc-vm] + +TASK [docker : Install Docker prerequisites] **************************************************************************ok: [yc-vm] + +TASK [docker : Create keyrings directory] *****************************************************************************ok: [yc-vm] + +TASK [docker : Add Docker GPG key] ************************************************************************************ok: [yc-vm] + +TASK [docker : Add Docker repository] *********************************************************************************ok: [yc-vm] + +TASK [docker : Install Docker packages] *******************************************************************************ok: [yc-vm] + +TASK [docker : Ensure Docker service is running and enabled] **********************************************************ok: [yc-vm] + +TASK [docker : Add users to docker group] *****************************************************************************ok: [yc-vm] => (item=ubuntu) + +TASK [docker : Install python3-docker for Ansible modules] ************************************************************ok: [yc-vm] + +PLAY RECAP ************************************************************************************************************yc-vm : ok=12 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + +### Analysis + +**First run:** Tasks like "Install common packages", "Add Docker GPG key", "Install Docker packages" show `changed` because packages are being installed for the first time. + +**Second run:** All tasks show `ok` because: +- `apt` with `state: present` checks if package is already installed +- `service` with `state: started` checks if service is already running +- `file` with `state: directory` checks if directory exists +- `apt_repository` checks if repo is already present + +This is **idempotency** — running the same playbook multiple times produces the same end state without making unnecessary changes. + +--- + +## 4. Ansible Vault Usage + +### How Credentials Are Stored + +Sensitive data (Docker Hub username/password, app configuration) is stored in `group_vars/all.yml`, which is encrypted with `ansible-vault encrypt`. + +### Vault Password Management + +The vault password is stored in a `.vault_pass` file locally, which is: +- Added to `.gitignore` (never committed) +- Referenced in `ansible.cfg` or passed via `--ask-vault-pass` + +### Encrypted File Example + +``` +$ANSIBLE_VAULT;1.1;AES256 +37643638366134366632643037313965636261626130613639373466353633613063356433326334 +3831376333363339313631343533663762616432666232620a633937633162303336643962653035 +63633764346266386162313665363639333732626630396237653739636131653464616531663662 +3065356563626364380a353339323463353934303630376531646466646439616565663734623137 +31653139663837613762343431656665346331353064663533653035313538613737623861383137 +62326662646136316466336138646563363537353937393534656335363337363839363334333463 +34323065613466333838396362633034626332303131396538613563336338326530633364336465 +63333962306564336162353764626565303739653934343433633732363139376363326365663765 +63303732373535656331633032636139373162343734623261653837353264633565393432313932 +64393936636164343735333061623639656634336432353936643565313031303966333739323630 +32313162653639613932383637306564666635353164383861323065306133326362353862623361 +33323737633833353739373338313732313461393630303665653233333964333039626637333239 +35386431386365353762303835646636313531626334373836643866383030656431303432626263 +34626132316434643038623930626632353033353638303034663737373437646139363431656236 +38656637376134353765343239663262343739663935653763303336346237343231616134383035 +38663562646538643432666330633133396465396262316365303439363033373536633630343138 +30353766396235303631373039353839343935313038303733333936303763373362 + +``` + +### Why Ansible Vault Is Important + +Without Vault, credentials would need to be stored in plaintext in version control or passed through environment variables. Vault allows secrets to be committed alongside code (encrypted) while remaining accessible only to authorized users with the vault password. + +--- + +## 5. Deployment Verification + +### Deployment Output + +``` +PLAY [Deploy application] ********************************************************************************************* + +TASK [Gathering Facts] ************************************************************************************************ +ok: [yc-vm] + +TASK [app_deploy : Log in to Docker Hub] ****************************************************************************** +ok: [yc-vm] + +TASK [app_deploy : Pull application Docker image] ********************************************************************* +changed: [yc-vm] + +TASK [app_deploy : Stop existing application container] *************************************************************** +ok: [yc-vm] + +TASK [app_deploy : Run application container] ************************************************************************* +changed: [yc-vm] + +TASK [app_deploy : Wait for application port to be available] ********************************************************* +ok: [yc-vm] + +TASK [app_deploy : Verify application health endpoint] **************************************************************** +ok: [yc-vm] + +TASK [app_deploy : Display health check result] *********************************************************************** +ok: [yc-vm] => { + "health_result.json": { + "status": "healthy", + "timestamp": "2026-02-26T10:26:18.354036", + "uptime_seconds": 8 + } +} + +RUNNING HANDLER [app_deploy : restart app container] ****************************************************************** +changed: [yc-vm] + +PLAY RECAP ************************************************************************************************************ +yc-vm : ok=9 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +### Health Check + +```bash +$ curl http://89.169.137.6:5000/health +StatusCode : 200 +StatusDescription : OK +Content : {"status":"healthy","timestamp":"2026-02-26T10:29:49.981915","uptime_seconds":207} +RawContent : HTTP/1.1 200 OK + Content-Length: 82 + Content-Type: application/json + Date: Thu, 26 Feb 2026 10:29:49 GMT + Server: uvicorn + + {"status":"healthy","timestamp":"2026-02-26T10:29:49.981915","uptime_second... +Forms : {} +Headers : {[Content-Length, 82], [Content-Type, application/json], [Date, Thu, 26 Feb 2026 10:29:49 GMT], [S + erver, uvicorn]} +Images : {} +InputFields : {} +Links : {} +ParsedHtml : mshtml.HTMLDocumentClass +RawContentLength : 82 +``` + + +--- + +## 6. Key Decisions + +**Why use roles instead of plain playbooks?** +Roles organize tasks into reusable, self-contained units with standardized structure. This makes code maintainable, shareable (via Ansible Galaxy), and testable independently. + +**How do roles improve reusability?** +A role like `docker` can be used across any project that needs Docker installed—simply include it in a playbook. Variables in `defaults/` allow customization without modifying role code. + +**What makes a task idempotent?** +Using Ansible modules that check current state before making changes (e.g., `apt: state=present` only installs if not present, `service: state=started` only starts if stopped). Avoid `shell`/`command` when a dedicated module exists. + +**How do handlers improve efficiency?** +Handlers run only when notified and only once at the end of a play. This prevents unnecessary service restarts—Docker is restarted only when its packages are actually installed or updated, not on every playbook run. + +**Why is Ansible Vault necessary?** +Production deployments require credentials (Docker registry tokens, API keys). Vault encrypts these so they can live in Git safely. Without Vault, teams resort to insecure practices like plaintext secrets or manual credential passing. + +--- + +## 7. Challenges + +- **Windows compatibility:** Ansible doesn't run natively on Windows. Solved by creating a Docker container as the control node with Ansible installed. +- **Docker GPG key method:** The `apt_key` module is deprecated. Used `get_url` to download the key to `/etc/apt/keyrings/` instead (modern approach matching Docker's official docs). +- **SSH key permissions in Docker:** Mounting Windows SSH keys into Linux containers yields `0777` permissions, which SSH rejects. Solved with `entrypoint.sh` that copies keys from a read-only mount (`/root/.ssh-mount`) to `/root/.ssh` with `chmod 600`. +- **ansible.cfg world-writable directory:** Docker-mounted `/ansible` is world-writable, so Ansible ignores `ansible.cfg` there. Fixed by copying `ansible.cfg` into `/etc/ansible/` during Docker build and using `ANSIBLE_CONFIG` env var. diff --git a/ansible/docs/LAB06.md b/ansible/docs/LAB06.md new file mode 100644 index 0000000000..70d67b78e0 --- /dev/null +++ b/ansible/docs/LAB06.md @@ -0,0 +1,336 @@ +# Lab 6: Advanced Ansible & CI/CD - Submission +--- + +**Name:** Aidar Sarvartdinov +**Date:** 2026-03-05 +**Lab Points:** 10 + +## Task 1 + +### Common Role + +Refactored `roles/common/tasks/main.yml`: +- **`packages` block** — groups apt cache update + package install with `rescue` (runs `apt-get update --fix-missing` on failure) and `always` (logs completion to `/tmp/common_packages_done.log`). +- **`users` block** — groups timezone configuration under the `users` tag. +- `become: true` applied at block level. + +### Docker Role + +Refactored `roles/docker/tasks/main.yml`: +- **`docker_install` block** — groups all Docker installation tasks (prerequisites, GPG key, repo, packages). Rescue waits 10 seconds and retries. Always ensures Docker service is enabled. +- **`docker_config` block** — groups user group assignment and python3-docker install under `docker_config` tag. + +### Tag Strategy + +| Tag | Scope | +|-----|-------| +| `packages` | Common package installation | +| `users` | User/system configuration | +| `common` | Entire common role (role-level) | +| `docker` | Entire docker role (role-level) | +| `docker_install` | Docker installation only | +| `docker_config` | Docker configuration only | +| `app_deploy` | Application deployment | +| `compose` | Docker Compose tasks | +| `web_app` | Entire web_app role (role-level) | +| `web_app_wipe` | Wipe logic | + +### Output: --list-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] +``` + +``` +$ ansible-playbook playbooks/deploy.yml --list-tags + +playbook: playbooks/deploy.yml + + play #1 (webservers): Deploy application TAGS: [] + TASK TAGS: [app_deploy, compose, docker_config, docker_install, web_app, web_app_wipe] +``` + +### Output: Selective execution with --tags + +``` +$ ansible-playbook playbooks/provision.yml --tags "docker" --check + +PLAY [Provision web servers] ***************************** + +TASK [Gathering Facts] *********************************** +ok: [yc-vm] + +TASK [docker : Install Docker prerequisites] ************* +ok: [yc-vm] + +TASK [docker : Create keyrings directory] **************** +ok: [yc-vm] + +TASK [docker : Add Docker GPG key] *********************** +changed: [yc-vm] + +TASK [docker : Add Docker repository] ******************** +ok: [yc-vm] + +TASK [docker : Install Docker packages] ****************** +ok: [yc-vm] + +TASK [docker : Ensure Docker service is enabled and started] **** +ok: [yc-vm] + +TASK [docker : Add users to docker group] **************** +ok: [yc-vm] => (item=ubuntu) + +TASK [docker : Install python3-docker for Ansible modules] **** +ok: [yc-vm] +``` + +> Common role tasks (packages, users) are skipped — only docker-tagged tasks executed. + +--- + +## Task 2 + +### Migration + +Renamed `app_deploy` → `web_app` role. Replaced `docker_container` module with Docker Compose using Jinja2 template. + +### Docker Compose Template + +`roles/web_app/templates/docker-compose.yml.j2`: + +```yaml +--- +services: + {{ app_name }}: + image: {{ docker_image }}:{{ docker_tag }} + container_name: {{ app_name }} + ports: + - "{{ app_port }}:{{ app_internal_port }}" + environment: + APP_NAME: {{ app_name }} + restart: unless-stopped +``` + +### Role Dependencies + +`roles/web_app/meta/main.yml` declares `docker` as a dependency — Docker auto-installs when running `web_app`. + +### Output: Deployment + +``` +$ ansible-playbook playbooks/deploy.yml + +PLAY [Deploy application] ******************************** + +TASK [Gathering Facts] *********************************** +ok: [yc-vm] + +TASK [docker : Install Docker prerequisites] ************* +ok: [yc-vm] +... +TASK [docker : Ensure Docker service is enabled and started] **** +ok: [yc-vm] +... +TASK [web_app : Include wipe tasks] ********************** +included: /ansible/roles/web_app/tasks/wipe.yml for yc-vm + +TASK [web_app : Stop and remove containers] ************** +skipping: [yc-vm] +... +TASK [web_app : Create application directory] ************ +ok: [yc-vm] + +TASK [web_app : Template docker-compose file] ************ +ok: [yc-vm] + +TASK [web_app : Remove old standalone container if exists] **** +ok: [yc-vm] + +TASK [web_app : Pull and start containers with docker compose] **** +changed: [yc-vm] + +TASK [web_app : Wait for application port] *************** +ok: [yc-vm] + +TASK [web_app : Verify application health] *************** +ok: [yc-vm] => {"status": 200, "json": {"status": "healthy", "timestamp": "2026-03-04T20:39:54.476622", "uptime_seconds": 5}} + +TASK [web_app : Display health check result] ************* +ok: [yc-vm] + +PLAY RECAP *********************************************** +yc-vm : ok=17 changed=1 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0 +``` + +### Output: Idempotency (2nd run) + +``` +$ ansible-playbook playbooks/deploy.yml # 2nd run + +PLAY RECAP *********************************************** +yc-vm : ok=17 changed=1 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0 +``` + +> `changed=1` — only the `docker compose up --force-recreate` task (recreates container each time), all other tasks show `ok`. + +--- + +## Task 3 + +### Implementation + +Created `roles/web_app/tasks/wipe.yml` with double-gating: +1. **Variable gate:** `when: web_app_wipe | bool` (default: `false`) +2. **Tag gate:** tagged with `web_app_wipe` + +Wipe sequence: `docker compose down` → remove compose file → remove directory → remove image. + +### Test Scenarios + +| # | Command | Expected | Result | +|---|---------|----------|--------| +| 1 | `ansible-playbook deploy.yml` | Normal deploy, wipe skipped | Wipe tasks show `skipping` (tag not specified) | +| 2 | `deploy.yml -e "web_app_wipe=true" --tags web_app_wipe` | Wipe only, no deploy | Wipe runs, deploy skipped | +| 3 | `deploy.yml -e "web_app_wipe=true"` | Wipe then fresh deploy | Wipe → deploy → app healthy | +| 4 | `deploy.yml --tags web_app_wipe` | Wipe skipped (`when` blocks) | `skip_reason: Conditional result was False` | + +### Output: Scenario 1 (wipe skipped during normal deploy) + +From the deploy output above: +``` +TASK [web_app : Stop and remove containers] ************** +skipping: [yc-vm] => {"skip_reason": "Conditional result was False"} + +TASK [web_app : Remove docker-compose file] ************** +skipping: [yc-vm] => {"skip_reason": "Conditional result was False"} + +TASK [web_app : Remove application directory] ************ +skipping: [yc-vm] => {"skip_reason": "Conditional result was False"} + +TASK [web_app : Remove Docker image] ********************* +skipping: [yc-vm] => {"skip_reason": "Conditional result was False"} + +TASK [web_app : Log wipe completion] ********************* +skipping: [yc-vm] +``` + +### Output: Scenario 2 (wipe only) + +``` +$ ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe + +PLAY [Deploy application] ******************************** + +TASK [Gathering Facts] *********************************** +ok: [yc-vm] + +TASK [web_app : Include wipe tasks] ********************** +included: /ansible/roles/web_app/tasks/wipe.yml for yc-vm + +TASK [web_app : Stop and remove containers] ************** +changed: [yc-vm] + +TASK [web_app : Remove docker-compose file] ************** +changed: [yc-vm] + +TASK [web_app : Remove application directory] ************ +changed: [yc-vm] + +TASK [web_app : Remove Docker image] ********************* +changed: [yc-vm] + +TASK [web_app : Log wipe completion] ********************* +ok: [yc-vm] => {"msg": "Application pythonapp wiped successfully"} + +PLAY RECAP *********************************************** +yc-vm : ok=7 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +> Deploy tasks are not executed — only wipe-tagged tasks run. + +### Output: Scenario 3 (clean reinstall) + +``` +$ ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" + +PLAY [Deploy application] ******************************** +... +TASK [web_app : Include wipe tasks] ********************** +included: /ansible/roles/web_app/tasks/wipe.yml for yc-vm + +TASK [web_app : Stop and remove containers] ************** +changed: [yc-vm] +... +TASK [web_app : Pull and start containers with docker compose] **** +changed: [yc-vm] +... +PLAY RECAP *********************************************** +yc-vm : ok=22 changed=7 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +> Both wipe and deploy tasks execute, resulting in a clean reinstallation. + +### Output: Scenario 4a (safety test, variable not set) + +``` +$ ansible-playbook playbooks/deploy.yml --tags web_app_wipe + +PLAY [Deploy application] ******************************** + +TASK [Gathering Facts] *********************************** +ok: [yc-vm] + +TASK [web_app : Include wipe tasks] ********************** +included: /ansible/roles/web_app/tasks/wipe.yml for yc-vm + +TASK [web_app : Stop and remove containers] ************** +skipping: [yc-vm] + +TASK [web_app : Remove docker-compose file] ************** +skipping: [yc-vm] + +TASK [web_app : Remove application directory] ************ +skipping: [yc-vm] + +TASK [web_app : Remove Docker image] ********************* +skipping: [yc-vm] + +TASK [web_app : Log wipe completion] ********************* +skipping: [yc-vm] + +PLAY RECAP *********************************************** +yc-vm : ok=2 changed=0 unreachable=0 failed=0 skipped=5 rescued=0 ignored=0 +``` + +> Wipe is blocked because `web_app_wipe` variable defaults to false (`when` condition fails). + +--- + +## Task 4 + +### Workflow + +`.github/workflows/ansible-deploy.yml`: + +``` +Push to ansible/** → Lint Job (ansible-lint) → Deploy Job (SSH + ansible-playbook) → Verify (curl) +``` + +- **Lint job:** installs ansible + ansible-lint, runs `ansible-lint playbooks/*.yml` +- **Deploy job:** sets up SSH via GitHub Secrets, decrypts vault, runs `ansible-playbook playbooks/deploy.yml`, verifies with `curl` +- **Path filters:** triggers only on `ansible/**` changes (excludes `docs/`) +- **Deploy job** only runs on `main`/`master` branch pushes (not on PRs) + +### Required GitHub Secrets + +| Secret | Purpose | +|--------|---------| +| `ANSIBLE_VAULT_PASSWORD` | Vault decryption | +| `SSH_PRIVATE_KEY` | SSH access to VM | +| `VM_HOST` | Target VM IP | diff --git a/ansible/entrypoint.sh b/ansible/entrypoint.sh new file mode 100644 index 0000000000..6369619663 --- /dev/null +++ b/ansible/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Copy SSH keys from mounted read-only volume and fix permissions +if [ -d /root/.ssh-mount ]; then + cp -r /root/.ssh-mount/* /root/.ssh/ 2>/dev/null + chmod 700 /root/.ssh + chmod 600 /root/.ssh/* 2>/dev/null + chmod 644 /root/.ssh/*.pub 2>/dev/null +fi + +exec "$@" diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000000..23d20e8087 --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,18 @@ +$ANSIBLE_VAULT;1.1;AES256 +37643638366134366632643037313965636261626130613639373466353633613063356433326334 +3831376333363339313631343533663762616432666232620a633937633162303336643962653035 +63633764346266386162313665363639333732626630396237653739636131653464616531663662 +3065356563626364380a353339323463353934303630376531646466646439616565663734623137 +31653139663837613762343431656665346331353064663533653035313538613737623861383137 +62326662646136316466336138646563363537353937393534656335363337363839363334333463 +34323065613466333838396362633034626332303131396538613563336338326530633364336465 +63333962306564336162353764626565303739653934343433633732363139376363326365663765 +63303732373535656331633032636139373162343734623261653837353264633565393432313932 +64393936636164343735333061623639656634336432353936643565313031303966333739323630 +32313162653639613932383637306564666635353164383861323065306133326362353862623361 +33323737633833353739373338313732313461393630303665653233333964333039626637333239 +35386431386365353762303835646636313531626334373836643866383030656431303432626263 +34626132316434643038623930626632353033353638303034663737373437646139363431656236 +38656637376134353765343239663262343739663935653763303336346237343231616134383035 +38663562646538643432666330633133396465396262316365303439363033373536633630343138 +30353766396235303631373039353839343935313038303733333936303763373362 diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..a5cf806e4d --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,2 @@ +[webservers] +yc-vm ansible_host=89.169.137.6 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_rsa ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml new file mode 100644 index 0000000000..b046372407 --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,9 @@ +--- +- name: Deploy application + hosts: webservers + become: true + + roles: + - role: web_app + tags: + - web_app diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..6334c412cc --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -0,0 +1,12 @@ +--- +- 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..8b048a22b1 --- /dev/null +++ b/ansible/playbooks/site.yml @@ -0,0 +1,6 @@ +--- +- name: Full site setup + import_playbook: provision.yml + +- name: Deploy applications + import_playbook: deploy.yml diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..bcdac2abdb --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,14 @@ +--- +common_packages: + - python3-pip + - curl + - git + - vim + - htop + - wget + - unzip + - net-tools + - ca-certificates + - gnupg + +timezone: UTC diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000000..a924276866 --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,42 @@ +--- +- name: Install common packages + block: + - name: Update apt cache + apt: + update_cache: yes + cache_valid_time: 3600 + + - name: Install packages + apt: + name: "{{ common_packages }}" + state: present + + rescue: + - name: Fix apt cache and retry + command: apt-get update --fix-missing + + - name: Retry package installation + apt: + name: "{{ common_packages }}" + state: present + + always: + - name: Log package installation completion + copy: + content: "Package installation completed at {{ ansible_date_time.iso8601 }}" + dest: /tmp/common_packages_done.log + mode: "0644" + + become: true + tags: + - packages + +- name: Configure users and system + 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..e8f950e600 --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,10 @@ +--- +docker_users: + - 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..3627303e6b --- /dev/null +++ b/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart docker + 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..fe03547e10 --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,82 @@ +--- + +- name: Install Docker + block: + - name: Install Docker prerequisites + apt: + name: + - apt-transport-https + - ca-certificates + - curl + - gnupg + state: present + + - name: Create keyrings directory + 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 + 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 + apt: + name: "{{ docker_packages }}" + state: present + update_cache: yes + + rescue: + - name: Wait before retrying + pause: + seconds: 10 + + - name: Retry apt update + apt: + update_cache: yes + + - name: Retry Docker packages installation + apt: + name: "{{ docker_packages }}" + state: present + + always: + - name: Ensure Docker service is enabled and started + service: + name: docker + state: started + enabled: yes + + become: true + tags: + - docker_install + +- name: Configure Docker + block: + - name: Add users to docker group + user: + name: "{{ item }}" + groups: docker + append: yes + loop: "{{ docker_users }}" + + - name: Install python3-docker for Ansible modules + apt: + name: python3-docker + state: present + + 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..868efe4f4f --- /dev/null +++ b/ansible/roles/web_app/defaults/main.yml @@ -0,0 +1,14 @@ +--- +app_name: pythonapp +docker_image: aidarsarvartdinov/pythonapp +docker_tag: latest +app_port: 5000 +app_internal_port: 5000 + +# Docker Compose Config +compose_project_dir: "/opt/{{ app_name }}" + +# Wipe Logic Control (default: disabled) +# 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..1825fc27e0 --- /dev/null +++ b/ansible/roles/web_app/handlers/main.yml @@ -0,0 +1,3 @@ +--- +- name: restart app container + command: docker compose -f {{ compose_project_dir }}/docker-compose.yml restart 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..ccbfaf5712 --- /dev/null +++ b/ansible/roles/web_app/tasks/main.yml @@ -0,0 +1,59 @@ +--- +- name: Include wipe tasks + include_tasks: wipe.yml + tags: + - web_app_wipe + +- name: Deploy application with Docker Compose + block: + - name: Create application directory + file: + path: "{{ compose_project_dir }}" + state: directory + mode: "0755" + + - name: Template docker-compose file + template: + src: docker-compose.yml.j2 + dest: "{{ compose_project_dir }}/docker-compose.yml" + mode: "0644" + + - name: Remove old standalone container if exists + command: docker rm -f {{ app_name }} + ignore_errors: yes + changed_when: false + + - name: Pull and start containers with docker compose + command: docker compose -f {{ compose_project_dir }}/docker-compose.yml up -d --pull always --force-recreate + register: compose_result + changed_when: "'Started' in compose_result.stderr or 'Created' in compose_result.stderr" + + - name: Wait for application port + wait_for: + port: "{{ app_port }}" + host: localhost + delay: 5 + timeout: 60 + + - name: Verify application health + uri: + url: "http://localhost:{{ app_port }}/health" + return_content: yes + status_code: 200 + register: health_result + ignore_errors: yes + + - name: Display health check result + debug: + var: health_result.json + when: health_result is defined and health_result.json is defined + + rescue: + - name: Log deployment failure + debug: + msg: "Deployment of {{ app_name }} failed. Check logs with: docker compose -f {{ compose_project_dir }}/docker-compose.yml logs" + + become: true + tags: + - app_deploy + - compose diff --git a/ansible/roles/web_app/tasks/wipe.yml b/ansible/roles/web_app/tasks/wipe.yml new file mode 100644 index 0000000000..cbff310008 --- /dev/null +++ b/ansible/roles/web_app/tasks/wipe.yml @@ -0,0 +1,29 @@ +--- +- name: Wipe web application + block: + - name: Stop and remove containers + command: docker compose -f {{ compose_project_dir }}/docker-compose.yml down --remove-orphans + ignore_errors: yes + + - name: Remove docker-compose file + file: + path: "{{ compose_project_dir }}/docker-compose.yml" + state: absent + + - name: Remove application directory + file: + path: "{{ compose_project_dir }}" + state: absent + + - name: Remove Docker image + command: docker rmi {{ docker_image }}:{{ docker_tag }} + ignore_errors: yes + + - name: Log wipe completion + debug: + msg: "Application {{ app_name }} wiped successfully" + + when: web_app_wipe | bool + become: true + tags: + - web_app_wipe diff --git a/ansible/roles/web_app/templates/docker-compose.yml.j2 b/ansible/roles/web_app/templates/docker-compose.yml.j2 new file mode 100644 index 0000000000..ba1f9343d2 --- /dev/null +++ b/ansible/roles/web_app/templates/docker-compose.yml.j2 @@ -0,0 +1,10 @@ +--- +services: + {{ app_name }}: + image: {{ docker_image }}:{{ docker_tag }} + container_name: {{ app_name }} + ports: + - "{{ app_port }}:{{ app_internal_port }}" + environment: + APP_NAME: {{ app_name }} + restart: unless-stopped diff --git a/app_java/.dockerignore b/app_java/.dockerignore new file mode 100644 index 0000000000..f71a70fbb2 --- /dev/null +++ b/app_java/.dockerignore @@ -0,0 +1,20 @@ +.git +.gitignore + +.vscode + +target/ + +.mvn/ + +mvnw +mvnw.cmd + +.env +*.pem +secrets/ + +*.md +docs/ + +tests/ diff --git a/app_java/.gitattributes b/app_java/.gitattributes new file mode 100644 index 0000000000..3b41682ac5 --- /dev/null +++ b/app_java/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/app_java/.gitignore b/app_java/.gitignore new file mode 100644 index 0000000000..667aaef0c8 --- /dev/null +++ b/app_java/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/app_java/.mvn/wrapper/maven-wrapper.properties b/app_java/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000..8dea6c227c --- /dev/null +++ b/app_java/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip diff --git a/app_java/Dockerfile b/app_java/Dockerfile new file mode 100644 index 0000000000..dd2c53d621 --- /dev/null +++ b/app_java/Dockerfile @@ -0,0 +1,23 @@ +FROM maven:3.9.6-eclipse-temurin-21 AS builder + +WORKDIR /app + +COPY pom.xml . + +RUN mvn dependency:go-offline -B + +COPY src ./src + +RUN mvn clean package + +FROM eclipse-temurin:21-jre-alpine AS runner + +RUN addgroup -S spring && adduser -S spring -G spring + +COPY --from=builder ./app/target/app_java-0.0.1-SNAPSHOT.jar ./app.jar + +USER spring:spring + +EXPOSE 5000 + +CMD [ "java", "-jar", "./app.jar" ] diff --git a/app_java/README.md b/app_java/README.md new file mode 100644 index 0000000000..1a789b8327 --- /dev/null +++ b/app_java/README.md @@ -0,0 +1,50 @@ +# DevOps Info Service + +A web application providing detailed system information and health status for DevOps monitoring. + +## Overview + +This service provides comprehensive information about: +- Service metadata (name, version, description) +- System information (hostname, platform, CPU, etc.) +- Runtime information (uptime, current time) +- Request details (client IP, user agent) +- Health status for monitoring + +## Prerequisites + +- Java 21 or higher +- Maven + +## Installation + +1. Clone the repository + +## Building +```bash +mvn clean package +``` + +## Running the Application +```bash +java -jar target/app_java-0.0.1-SNAPSHOT.jar +``` + +With custom configuration: +```bash +PORT=8080 java -jar target/app_java-0.0.1-SNAPSHOT.jar +HOST=127.0.0.1 PORT=3000 java -jar target/app_java-0.0.1-SNAPSHOT.jar +DEBUG=true java -jar target/app_java-0.0.1-SNAPSHOT.jar +``` + +## API Endpoints +- GET / - Service and system information +- GET /health - Health check + + +## Configuration +| Variable | Default | Description | +| -------- | --------- | ------------------------------- | +| `HOST` | `0.0.0.0` | Host to bind the server to | +| `PORT` | `5000` | Port to listen on | +| `DEBUG` | `False` | Enable debug mode | diff --git a/app_java/docs/JAVA.md b/app_java/docs/JAVA.md new file mode 100644 index 0000000000..d38ea995d8 --- /dev/null +++ b/app_java/docs/JAVA.md @@ -0,0 +1,2 @@ +## Language justification +I chose Java and Spring Boot because it is the Enterprise standard and I want to be a java developer diff --git a/app_java/docs/LAB01.md b/app_java/docs/LAB01.md new file mode 100644 index 0000000000..f344157c10 --- /dev/null +++ b/app_java/docs/LAB01.md @@ -0,0 +1,126 @@ + +## Best Practices Applied +### Clean Code Organization +```java +# Grouped and ordered imports +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.app_java.dto.HealthResponse; +import com.example.app_java.dto.InfoResponse; +import com.example.app_java.service.InfoService; + +import jakarta.servlet.ServletRequest; +``` +Importance: Proper import organization improves readability and avoids circular dependencies. + +### Separation of Concerns +```java +private ServiceInfo getServiceInfo() { + ServiceInfo serviceInfo = new ServiceInfo(); + serviceInfo.setName(appName); + ... + } + + private RuntimeInfo getRuntimeInfo() { + RuntimeInfo info = new RuntimeInfo(); + + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); + ... + } +``` + +Importance: Each function has a single responsibility, making code easier to test, maintain, and reuse. + + + +### Environment-Based Configuration + +``` +server.address=${HOST:localhost} +server.port=${PORT:5000} +debug=${DEBUG:false} +``` + +Importance: Allows configuration changes without code modifications + +### Logging +```java +private static final Logger log = LoggerFactory.getLogger(InfoController.class); + +log.info("GET / requested from {}", request.getRemoteAddr()); +``` + +Importance: Provides operational monitoring + + +### Error Handling +```java +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity handleNotFound(NoHandlerFoundException ex, + HttpServletRequest request) { + ErrorResponse error = new ErrorResponse(); + error.setError("Not Found"); + error.setMessage(String.format("Endpoint %s does not exist", request.getRequestURI())); + error.setTimestamp(ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)); + + log.warn("404 Not Found: {}", request.getRequestURI()); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex, + HttpServletRequest request) { + ... + } +} +``` +Importance: Provides clear error messages to users, prevents leakage of internal information + + +## API Documentation + +### GET / + +Request: curl -X GET "http://localhost:5000/" + +Response: +```json +{"service":{"name":"devops-info-service","version":"1.0.0","description":"DevOps course info service","framework":"Spring Boot"},"system":{"hostname":"DESKTOP-2Q0E6TS","platform":"Windows 10","platformVersion":"10.0","architecture":"amd64","cpuCount":8,"javaVersion":"25"},"runtime":{"uptimeSeconds":148,"uptimeHuman":"0 hours, 2 minutes","currentTime":"2026-01-26T07:36:11.610985500Z","timezone":"UTC"},"request":{"clientIp":"127.0.0.1","userAgent":"curl/8.13.0","method":"GET","path":"/"},"endpoints":[{"path":"/","method":"GET","description":"Service information"},{"path":"/health","method":"GET","description":"Health check"}]} +``` +### GET /health + +Request: curl -X GET "http://localhost:5000/health" + +Response: +```json +{"status":"healthy","timestamp":"2026-01-26T07:38:08.945379700Z","uptimeSeconds":265} +``` + +## Testing Evidence + +Terminal Output +``` +2026-01-26T10:33:43.588+03:00 INFO 3276 --- [devops-info-service] [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on +port 5000 (http) with context path '/' +2026-01-26T10:33:43.625+03:00 INFO 3276 --- [devops-info-service] [ main] c.example.app_java.AppJavaApplication : Started AppJavaApplication in 2.77 seconds (process running for 3.364) +2026-01-26T10:36:11.543+03:00 INFO 3276 --- [devops-info-service] [0.1-5000-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet' +2026-01-26T10:36:11.544+03:00 INFO 3276 --- [devops-info-service] [0.1-5000-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet' +2026-01-26T10:36:11.546+03:00 INFO 3276 --- [devops-info-service] [0.1-5000-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms +2026-01-26T10:36:11.600+03:00 INFO 3276 --- [devops-info-service] [0.1-5000-exec-1] c.e.app_java.controller.InfoController : GET / requested from 127.0.0.1 +``` + +## Size comparision + +The size of .jar file is 20mb + +The size of app.py with venv is 40mb diff --git a/app_java/docs/LAB02.md b/app_java/docs/LAB02.md new file mode 100644 index 0000000000..f1aaab664e --- /dev/null +++ b/app_java/docs/LAB02.md @@ -0,0 +1,104 @@ +## Multi-stage Build Strategy +Stage 1: Builder +Uses full Maven image with JDK 21 + +Installs all Maven dependencies + +Compiles application into JAR file + +Stage 2: Runtime +Uses minimal JRE Alpine image + +Copies only the compiled JAR file + + +## Size Comparison +Image Sizes: +Builder stage: 1.04GB (Maven + JDK + dependencies) + +Final image: 323MB + +Space Savings: +Savings: ~742MB + + +## Why Multi-stage Builds Matter for Compiled Languages + +Security: Fewer vulnerabilities in final image + +Size: Significant size reduction + +Attack surface: Only runtime environment present + + +## Teminal Output +docker build -t javaapp:1.0.0 . + + +```bash +[+] Building 173.1s (15/15) FINISHED docker:desktop-linux + => [internal] load build definition from Dockerfile 0.1s + => => transferring dockerfile: 453B 0.0s + => [internal] load metadata for docker.io/library/maven:3.9.6-eclipse-temurin-21 0.7s + => [internal] load metadata for docker.io/library/eclipse-temurin:21-jre-alpine 0.7s + => [internal] load .dockerignore 0.0s + => => transferring context: 157B 0.0s + => [builder 1/6] FROM docker.io/library/maven:3.9.6-eclipse-temurin-21@sha256:8d63d4c1902cb12d9e79a70671b18ebe26358cb592561af33ca1808f00d9 0.1s + => => resolve docker.io/library/maven:3.9.6-eclipse-temurin-21@sha256:8d63d4c1902cb12d9e79a70671b18ebe26358cb592561af33ca1808f00d935cb 0.1s + => CACHED [runner 1/3] FROM docker.io/library/eclipse-temurin:21-jre-alpine@sha256:08eecc477dbe3f2e33daac27f36e41daf7f4ec51d2f3396006e54fa 0.1s + => => resolve docker.io/library/eclipse-temurin:21-jre-alpine@sha256:08eecc477dbe3f2e33daac27f36e41daf7f4ec51d2f3396006e54fa41832c74c 0.1s + => [internal] load build context 0.0s + => => transferring context: 1.91kB 0.0s + => [runner 2/3] RUN addgroup -S spring && adduser -S spring -G spring 0.6s + => CACHED [builder 2/6] WORKDIR /app 0.0s + => CACHED [builder 3/6] COPY pom.xml . 0.0s + => [builder 5/6] COPY src ./src 0.1s + => [runner 3/3] COPY --from=builder ./app/target/app_java-0.0.1-SNAPSHOT.jar ./app.jar 0.1s + => exporting to image 2.2s + => => exporting layers 1.8s + => => exporting manifest sha256:3fb0ff01cf2c83ac6c47e430883faa8d522b8e55a9b05193be5ba00420a74dc2 0.0s + => => exporting config sha256:228d607e5908bf848b71dcbd8cbc0125a96288a9b2d4d0f12e4f592e11e20c0d 0.0s + => => exporting attestation manifest sha256:e3202aee20a5900a329f8fb1729f2b448991ae695df757ac1833fbc5d0936b06 0.0s + => => exporting manifest list sha256:9c6df41a190b49b5c43c43dd446dc8a9b77b43faa368a04c3ebcc95b1bc829fe 0.0s + => => naming to docker.io/library/javaapp:1.0.0 0.0s + => => unpacking to docker.io/library/javaapp:1.0.0 0.2s +``` + +docker images +```bash +javaapp-builder:1.0.0 eded1826a506 1.04GB 351MB +javaapp:1.0.0 9c6df41a190b 323MB 92.3MB +``` + +## Technical Explanation of Each Stage +Builder Stage: +```dockerfile +FROM maven:3.9.6-eclipse-temurin-21 AS builder +``` +Full build tooling + +JDK for compilation + +Maven for dependency management + +Runtime Stage: +```dockerfile +FROM eclipse-temurin:21-jre-alpine +``` +Minimal Linux image (Alpine) + +Only JRE for execution + +Minimal system dependencies + +Security Benefits +Fewer vulnerabilities: Only necessary packages + +No compiler: Attacker cannot compile code + +Non-root user: Limited privileges + + +## Trade-offs and Decisions +Trade-off: Production debugging +Solution: Separate debug images with full tooling diff --git a/app_java/docs/screenshots/01-main-enpoint.png b/app_java/docs/screenshots/01-main-enpoint.png new file mode 100644 index 0000000000..0174afaa8b Binary files /dev/null and b/app_java/docs/screenshots/01-main-enpoint.png differ diff --git a/app_java/docs/screenshots/02-health-check.png b/app_java/docs/screenshots/02-health-check.png new file mode 100644 index 0000000000..db8bb70c82 Binary files /dev/null and b/app_java/docs/screenshots/02-health-check.png differ diff --git a/app_java/docs/screenshots/03-formatted-output.png b/app_java/docs/screenshots/03-formatted-output.png new file mode 100644 index 0000000000..f749822817 Binary files /dev/null and b/app_java/docs/screenshots/03-formatted-output.png differ diff --git a/app_java/mvnw b/app_java/mvnw new file mode 100644 index 0000000000..bd8896bf22 --- /dev/null +++ b/app_java/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/app_java/mvnw.cmd b/app_java/mvnw.cmd new file mode 100644 index 0000000000..92450f9327 --- /dev/null +++ b/app_java/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/app_java/pom.xml b/app_java/pom.xml new file mode 100644 index 0000000000..7dfa87bb70 --- /dev/null +++ b/app_java/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.10 + + + com.example + app_java + 0.0.1-SNAPSHOT + app_java + Demo project for Spring Boot + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/app_java/src/main/java/com/example/app_java/AppJavaApplication.java b/app_java/src/main/java/com/example/app_java/AppJavaApplication.java new file mode 100644 index 0000000000..2712cea931 --- /dev/null +++ b/app_java/src/main/java/com/example/app_java/AppJavaApplication.java @@ -0,0 +1,13 @@ +package com.example.app_java; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AppJavaApplication { + + public static void main(String[] args) { + SpringApplication.run(AppJavaApplication.class, args); + } + +} diff --git a/app_java/src/main/java/com/example/app_java/controller/InfoController.java b/app_java/src/main/java/com/example/app_java/controller/InfoController.java new file mode 100644 index 0000000000..05840f1aeb --- /dev/null +++ b/app_java/src/main/java/com/example/app_java/controller/InfoController.java @@ -0,0 +1,38 @@ +package com.example.app_java.controller; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.app_java.dto.HealthResponse; +import com.example.app_java.dto.InfoResponse; +import com.example.app_java.service.InfoService; + +import jakarta.servlet.ServletRequest; + + +@RestController +public class InfoController { + private static final Logger log = LoggerFactory.getLogger(InfoController.class); + + private final InfoService infoService; + + public InfoController(InfoService infoService) { + this.infoService = infoService; + } + + @GetMapping("/") + public ResponseEntity getServiceInfo(ServletRequest request) { + log.info("GET / requested from {}", request.getRemoteAddr()); + return ResponseEntity.ok().body(infoService.getAppInfo()); + } + + @GetMapping("/health") + public ResponseEntity healthCheck() { + log.debug("Health check requested"); + return ResponseEntity.ok().body(infoService.getHealthStatus()); + } + +} diff --git a/app_java/src/main/java/com/example/app_java/dto/EndpointInfo.java b/app_java/src/main/java/com/example/app_java/dto/EndpointInfo.java new file mode 100644 index 0000000000..e3735f72c8 --- /dev/null +++ b/app_java/src/main/java/com/example/app_java/dto/EndpointInfo.java @@ -0,0 +1,31 @@ +package com.example.app_java.dto; + +public class EndpointInfo { + private String path; + private String method; + private String description; + + public EndpointInfo(String path, String method, String description) { + this.path = path; + this.method = method; + this.description = description; + } + public String getPath() { + return path; + } + public String getMethod() { + return method; + } + public String getDescription() { + return description; + } + public void setPath(String path) { + this.path = path; + } + public void setMethod(String method) { + this.method = method; + } + public void setDescription(String description) { + this.description = description; + } +} diff --git a/app_java/src/main/java/com/example/app_java/dto/ErrorResponse.java b/app_java/src/main/java/com/example/app_java/dto/ErrorResponse.java new file mode 100644 index 0000000000..0e089072b9 --- /dev/null +++ b/app_java/src/main/java/com/example/app_java/dto/ErrorResponse.java @@ -0,0 +1,33 @@ +package com.example.app_java.dto; + +public class ErrorResponse { + private String error; + private String message; + private String timestamp; + public ErrorResponse(String error, String message, String timestamp) { + this.error = error; + this.message = message; + this.timestamp = timestamp; + } + public ErrorResponse() { + //TODO Auto-generated constructor stub + } + public String getError() { + return error; + } + public String getMessage() { + return message; + } + public String getTimestamp() { + return timestamp; + } + public void setError(String error) { + this.error = error; + } + public void setMessage(String message) { + this.message = message; + } + public void setTimestamp(String timestamp) { + this.timestamp = timestamp; + } +} diff --git a/app_java/src/main/java/com/example/app_java/dto/HealthResponse.java b/app_java/src/main/java/com/example/app_java/dto/HealthResponse.java new file mode 100644 index 0000000000..b0acd29072 --- /dev/null +++ b/app_java/src/main/java/com/example/app_java/dto/HealthResponse.java @@ -0,0 +1,33 @@ +package com.example.app_java.dto; + +public class HealthResponse { + private String status; + private String timestamp; + private Long uptimeSeconds; + public HealthResponse(String status, String timestamp, Long uptimeSeconds) { + this.status = status; + this.timestamp = timestamp; + this.uptimeSeconds = uptimeSeconds; + } + public HealthResponse() { + //TODO Auto-generated constructor stub + } + public String getStatus() { + return status; + } + public String getTimestamp() { + return timestamp; + } + public Long getUptimeSeconds() { + return uptimeSeconds; + } + public void setStatus(String status) { + this.status = status; + } + public void setTimestamp(String timestamp) { + this.timestamp = timestamp; + } + public void setUptimeSeconds(Long uptimeSeconds) { + this.uptimeSeconds = uptimeSeconds; + } +} diff --git a/app_java/src/main/java/com/example/app_java/dto/InfoResponse.java b/app_java/src/main/java/com/example/app_java/dto/InfoResponse.java new file mode 100644 index 0000000000..7c40a2aa77 --- /dev/null +++ b/app_java/src/main/java/com/example/app_java/dto/InfoResponse.java @@ -0,0 +1,50 @@ +package com.example.app_java.dto; + +import java.util.List; + +public class InfoResponse { + private ServiceInfo service; + private SystemInfo system; + private RuntimeInfo runtime; + private RequestInfo request; + private List endpoints; + + public InfoResponse(ServiceInfo service, SystemInfo system, RuntimeInfo runtime, RequestInfo request, + List endpoints) { + this.service = service; + this.system = system; + this.runtime = runtime; + this.request = request; + this.endpoints = endpoints; + } + public ServiceInfo getService() { + return service; + } + public SystemInfo getSystem() { + return system; + } + public RuntimeInfo getRuntime() { + return runtime; + } + public RequestInfo getRequest() { + return request; + } + public List getEndpoints() { + return endpoints; + } + public void setService(ServiceInfo service) { + this.service = service; + } + public void setSystem(SystemInfo system) { + this.system = system; + } + public void setRuntime(RuntimeInfo runtime) { + this.runtime = runtime; + } + public void setRequest(RequestInfo request) { + this.request = request; + } + public void setEndpoints(List endpoints) { + this.endpoints = endpoints; + } +} diff --git a/app_java/src/main/java/com/example/app_java/dto/RequestInfo.java b/app_java/src/main/java/com/example/app_java/dto/RequestInfo.java new file mode 100644 index 0000000000..bbaced58d7 --- /dev/null +++ b/app_java/src/main/java/com/example/app_java/dto/RequestInfo.java @@ -0,0 +1,42 @@ +package com.example.app_java.dto; + +public class RequestInfo { + private String clientIp; + private String userAgent; + private String method; + private String path; + + public RequestInfo(String clientIp, String userAgent, String method, String path) { + this.clientIp = clientIp; + this.userAgent = userAgent; + this.method = method; + this.path = path; + } + public RequestInfo() { + //TODO Auto-generated constructor stub + } + public String getClientIp() { + return clientIp; + } + public String getUserAgent() { + return userAgent; + } + public String getMethod() { + return method; + } + public String getPath() { + return path; + } + public void setClientIp(String clientIp) { + this.clientIp = clientIp; + } + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } + public void setMethod(String method) { + this.method = method; + } + public void setPath(String path) { + this.path = path; + } +} diff --git a/app_java/src/main/java/com/example/app_java/dto/RuntimeInfo.java b/app_java/src/main/java/com/example/app_java/dto/RuntimeInfo.java new file mode 100644 index 0000000000..05ad829f4d --- /dev/null +++ b/app_java/src/main/java/com/example/app_java/dto/RuntimeInfo.java @@ -0,0 +1,51 @@ +package com.example.app_java.dto; + +public class RuntimeInfo { + private Long uptimeSeconds; + private String uptimeHuman; + private String currentTime; + private String timezone; + + public RuntimeInfo(Long uptimeSeconds, String uptimeHuman, String currentTime, String timezone) { + this.uptimeSeconds = uptimeSeconds; + this.uptimeHuman = uptimeHuman; + this.currentTime = currentTime; + this.timezone = timezone; + } + + public RuntimeInfo() { + //TODO Auto-generated constructor stub + } + + public Long getUptimeSeconds() { + return uptimeSeconds; + } + + public String getUptimeHuman() { + return uptimeHuman; + } + + public String getCurrentTime() { + return currentTime; + } + + public String getTimezone() { + return timezone; + } + + public void setUptimeSeconds(Long uptimeSeconds) { + this.uptimeSeconds = uptimeSeconds; + } + + public void setUptimeHuman(String uptimeHuman) { + this.uptimeHuman = uptimeHuman; + } + + public void setCurrentTime(String currentTime) { + this.currentTime = currentTime; + } + + public void setTimezone(String timezone) { + this.timezone = timezone; + } +} diff --git a/app_java/src/main/java/com/example/app_java/dto/ServiceInfo.java b/app_java/src/main/java/com/example/app_java/dto/ServiceInfo.java new file mode 100644 index 0000000000..7037429a83 --- /dev/null +++ b/app_java/src/main/java/com/example/app_java/dto/ServiceInfo.java @@ -0,0 +1,44 @@ +package com.example.app_java.dto; + +public class ServiceInfo { + public ServiceInfo(String name, String version, String description, String framework) { + this.name = name; + this.version = version; + this.description = description; + this.framework = framework; + } + + public ServiceInfo() { + //TODO Auto-generated constructor stub + } + + private String name; + private String version; + private String description; + private String framework; + + public String getName() { + return name; + } + public String getVersion() { + return version; + } + public String getDescription() { + return description; + } + public String getFramework() { + return framework; + } + public void setName(String name) { + this.name = name; + } + public void setVersion(String version) { + this.version = version; + } + public void setDescription(String description) { + this.description = description; + } + public void setFramework(String framework) { + this.framework = framework; + } +} diff --git a/app_java/src/main/java/com/example/app_java/dto/SystemInfo.java b/app_java/src/main/java/com/example/app_java/dto/SystemInfo.java new file mode 100644 index 0000000000..9497162ca2 --- /dev/null +++ b/app_java/src/main/java/com/example/app_java/dto/SystemInfo.java @@ -0,0 +1,72 @@ +package com.example.app_java.dto; + +public class SystemInfo { + private String hostname; + private String platform; + private String platformVersion; + private String architecture; + private Integer cpuCount; + private String javaVersion; + + public SystemInfo(String hostname, String platform, String platformVersion, String architecture, Integer cpuCount, + String javaVersion) { + this.hostname = hostname; + this.platform = platform; + this.platformVersion = platformVersion; + this.architecture = architecture; + this.cpuCount = cpuCount; + this.javaVersion = javaVersion; + } + + public SystemInfo() { + //TODO Auto-generated constructor stub + } + + public String getHostname() { + return hostname; + } + + public String getPlatform() { + return platform; + } + + public String getPlatformVersion() { + return platformVersion; + } + + public String getArchitecture() { + return architecture; + } + + public Integer getCpuCount() { + return cpuCount; + } + + public String getJavaVersion() { + return javaVersion; + } + + public void setHostname(String hostname) { + this.hostname = hostname; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public void setPlatformVersion(String platformVersion) { + this.platformVersion = platformVersion; + } + + public void setArchitecture(String architecture) { + this.architecture = architecture; + } + + public void setCpuCount(Integer cpuCount) { + this.cpuCount = cpuCount; + } + + public void setJavaVersion(String javaVersion) { + this.javaVersion = javaVersion; + } +} diff --git a/app_java/src/main/java/com/example/app_java/exception/GlobalExceptionHandler.java b/app_java/src/main/java/com/example/app_java/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000000..2e8432a8e3 --- /dev/null +++ b/app_java/src/main/java/com/example/app_java/exception/GlobalExceptionHandler.java @@ -0,0 +1,49 @@ +package com.example.app_java.exception; + +import jakarta.servlet.http.HttpServletRequest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; + +import com.example.app_java.dto.ErrorResponse; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity handleNotFound(NoHandlerFoundException ex, + HttpServletRequest request) { + ErrorResponse error = new ErrorResponse(); + error.setError("Not Found"); + error.setMessage(String.format("Endpoint %s does not exist", request.getRequestURI())); + error.setTimestamp(ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)); + + log.warn("404 Not Found: {}", request.getRequestURI()); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex, + HttpServletRequest request) { + ErrorResponse error = new ErrorResponse(); + error.setError("Internal Server Error"); + error.setMessage("An unexpected error occurred"); + error.setTimestamp(ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)); + + log.error("Internal server error: {}", request.getRequestURI(), ex); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } +} diff --git a/app_java/src/main/java/com/example/app_java/service/InfoService.java b/app_java/src/main/java/com/example/app_java/service/InfoService.java new file mode 100644 index 0000000000..bc72f67c8a --- /dev/null +++ b/app_java/src/main/java/com/example/app_java/service/InfoService.java @@ -0,0 +1,153 @@ +package com.example.app_java.service; + +import java.lang.management.ManagementFactory; +import java.lang.management.OperatingSystemMXBean; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.time.Duration; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import com.example.app_java.dto.EndpointInfo; +import com.example.app_java.dto.HealthResponse; +import com.example.app_java.dto.InfoResponse; +import com.example.app_java.dto.RequestInfo; +import com.example.app_java.dto.RuntimeInfo; +import com.example.app_java.dto.ServiceInfo; +import com.example.app_java.dto.SystemInfo; + +import jakarta.servlet.http.HttpServletRequest; + +@Service +public class InfoService { + private static final Logger log = LoggerFactory.getLogger(InfoService.class); + private static ZonedDateTime START_TIME = ZonedDateTime.now(ZoneOffset.UTC); + + private static final List endpoints = List.of( + new EndpointInfo("/", "GET", "Service information"), + new EndpointInfo("/health", "GET", "Health check")); + + @Value("${spring.application.name}") + private String appName; + + @Value("${spring.application.version}") + private String appVersion; + + @Value("${spring.application.description}") + private String appDescription; + + public InfoResponse getAppInfo() { + return new InfoResponse(getServiceInfo(), getSystemInfo(), getRuntimeInfo(), getRequestInfo(), endpoints); + } + + public HealthResponse getHealthStatus() { + HealthResponse response = new HealthResponse(); + + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); + Duration uptime = Duration.between(START_TIME, now); + response.setStatus("healthy"); + response.setTimestamp(ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)); + response.setUptimeSeconds(uptime.toSeconds()); + + return response; + } + + private SystemInfo getSystemInfo() { + SystemInfo info = new SystemInfo(); + + try { + info.setHostname(InetAddress.getLocalHost().getHostName()); + } catch (UnknownHostException e) { + info.setHostname("unknown"); + log.warn("Could not determine hostname", e); + } + + info.setPlatform(System.getProperty("os.name")); + info.setPlatformVersion(System.getProperty("os.version")); + info.setArchitecture(System.getProperty("os.arch")); + info.setJavaVersion(System.getProperty("java.version")); + + OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); + info.setCpuCount(osBean.getAvailableProcessors()); + + return info; + } + + private ServiceInfo getServiceInfo() { + ServiceInfo serviceInfo = new ServiceInfo(); + serviceInfo.setName(appName); + serviceInfo.setVersion(appVersion); + serviceInfo.setDescription(appDescription); + serviceInfo.setFramework("Spring Boot"); + + return serviceInfo; + } + + private RuntimeInfo getRuntimeInfo() { + RuntimeInfo info = new RuntimeInfo(); + + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); + + Duration uptime = Duration.between(START_TIME, now); + info.setUptimeSeconds(uptime.toSeconds()); + info.setUptimeHuman(formatUptime(uptime)); + info.setCurrentTime(now.format(DateTimeFormatter.ISO_INSTANT)); + info.setTimezone("UTC"); + + return info; + } + + private String formatUptime(Duration duration) { + long seconds = duration.toSeconds(); + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + + return String.format("%d hours, %d minutes", hours, minutes); + } + + private RequestInfo getRequestInfo() { + RequestInfo info = new RequestInfo(); + + Optional request = getCurrentHttpRequest(); + + if (request.isPresent()) { + HttpServletRequest httpRequest = request.get(); + info.setClientIp(getClientIp(httpRequest)); + info.setUserAgent(httpRequest.getHeader("User-Agent")); + info.setMethod(httpRequest.getMethod()); + info.setPath(httpRequest.getRequestURI()); + } else { + info.setClientIp("unknown"); + info.setUserAgent("unknown"); + info.setMethod("unknown"); + info.setPath("unknown"); + } + + return info; + } + + private String getClientIp(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isEmpty()) { + return xForwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } + + private Optional getCurrentHttpRequest() { + return Optional.ofNullable(RequestContextHolder.getRequestAttributes()) + .filter(ServletRequestAttributes.class::isInstance) + .map(ServletRequestAttributes.class::cast) + .map(ServletRequestAttributes::getRequest); + } +} diff --git a/app_java/src/main/resources/application.properties b/app_java/src/main/resources/application.properties new file mode 100644 index 0000000000..67590998dc --- /dev/null +++ b/app_java/src/main/resources/application.properties @@ -0,0 +1,7 @@ +server.address=${HOST:localhost} +server.port=${PORT:5000} +debug=${DEBUG:false} + +spring.application.name=devops-info-service +spring.application.version=1.0.0 +spring.application.description=DevOps course info service diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..e3b11cd940 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,17 @@ +.git +.gitignore + +__pycache__ +*.pyc +*.pyo +venv/ +.venv/ + +.env +*.pem +secrets/ + +*.md +docs/ + +tests/ diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..7d6baf680d --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,15 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +.venv/ +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +.env diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..c7ff75d2ac --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11.8-slim-bookworm + +RUN useradd --create-home --shell /bin/bash appuser + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +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..2476cebbaa --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,83 @@ +# DevOps Info Service + +A web application providing detailed system information and health status for DevOps monitoring. + +## Overview + +This service provides comprehensive information about: +- Service metadata (name, version, description) +- System information (hostname, platform, CPU, etc.) +- Runtime information (uptime, current time) +- Request details (client IP, user agent) +- Health status for monitoring + +## Prerequisites + +- Python 3.11 or higher +- pip package manager + +## Installation + +1. Clone the repository +2. Create and activate Python virtual environment: +``` +python -m venv venv + +# On Windows: +venv\Scripts\activate + +# On macOS/Linux: +source venv/bin/activate +``` +3. Install requirements: +``` +pip install -r requirements.txt +``` + +## Running the Application +``` +python app.py +``` + +With custom configuration: +``` +PORT=8080 python app.py +HOST=127.0.0.1 PORT=3000 python app.py +DEBUG=true python app.py +``` + +## API Endpoints +- GET / - Service and system information +- GET /health - Health check + + +## Configuration +| Variable | Default | Description | +| -------- | --------- | ------------------------------- | +| `HOST` | `0.0.0.0` | Host to bind the server to | +| `PORT` | `5000` | Port to listen on | +| `DEBUG` | `False` | Enable debug mode | + + +## Docker +Building the image locally +```bash +docker build -t : . +``` + +Running a container +```bash +docker run -d -p :8000 --name : +``` + +Pulling from Docker Hub +```bash +docker pull aidarsarvartdinov/pythonapp: +``` + +## Testing +To run tests locally after installing requirements: + +```bash +pytest +``` diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..af164b24d5 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,325 @@ +""" +DevOps Info Service +Main application module +""" +import os +import socket +import platform +import logging +import time +from datetime import datetime, timezone +from typing import Dict, Any + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from starlette.responses import Response +from starlette.middleware.base import BaseHTTPMiddleware + +from pythonjsonlogger import jsonlogger + +from prometheus_client import ( + Counter, Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST +) + +# Define metrics +http_requests = Counter( + "http_requests", + "Total HTTP requests", + ["method", "endpoint", "status"] +) + +http_request_duration_seconds = Histogram( + "http_request_duration_seconds", + "HTTP request duration", + ["method", "endpoint"] +) + +http_requests_in_progress = Gauge( + "http_requests_in_progress", + "HTTP requests currently being processed" +) + +devops_info_system_collection_seconds = Histogram( + "devops_info_system_collection_seconds", + "System info collection time" +) + + +logHandler = logging.StreamHandler() +formatter = jsonlogger.JsonFormatter( + fmt='%(asctime)s %(levelname)s %(message)s', + datefmt='%Y-%m-%dT%H:%M:%S%z', + rename_fields={ + 'asctime': 'timestamp', + 'levelname': 'level' + } +) +logHandler.setFormatter(formatter) +logging.basicConfig(level=logging.INFO, handlers=[logHandler]) +logger = logging.getLogger(__name__) + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.middleware("http") +async def metrics_middleware(request: Request, call_next): + method = request.method + endpoint = request.url.path + if endpoint == "/metrics": + return await call_next(request) + + http_requests_in_progress.inc() + start_time = time.time() + try: + response = await call_next(request) + status = str(response.status_code) + return response + except Exception as e: + status = "500" + raise e + finally: + duration = time.time() - start_time + http_requests.labels( + method=method, endpoint=endpoint, status=status).inc() + http_request_duration_seconds.labels( + method=method, endpoint=endpoint).observe(duration) + http_requests_in_progress.dec() + + +class LoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + start_time = time.time() + request.state.start_time = start_time + + client_ip = request.client.host if request.client else "unknown" + if "x-forwarded-for" in request.headers: + client_ip = \ + request.headers["x-forwarded-for"].split(",")[0].strip() + + try: + response = await call_next(request) + except Exception: + duration = time.time() - start_time + logger.error("HTTP request error", extra={ + "method": request.method, + "path": request.url.path, + "client_ip": client_ip, + "status_code": 500, + "duration": round(duration, 4) + }) + raise + + duration = time.time() - start_time + logger.info("HTTP request", extra={ + "method": request.method, + "path": request.url.path, + "status_code": response.status_code, + "client_ip": client_ip, + "duration": round(duration, 4) + }) + return response + + +app.add_middleware(LoggingMiddleware) + + +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', '5000')) +DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' + +START_TIME = datetime.now(timezone.utc) + +logger.info("Application starting", extra={ + "event": "startup", + "host": HOST, + "port": PORT, + "debug": DEBUG +}) + + +def get_uptime() -> Dict[str, Any]: + """Calculate application uptime since start""" + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return { + 'seconds': seconds, + 'human': f"{hours} hours, {minutes} minutes" + } + + +def get_system_info() -> Dict[str, Any]: + """Collect system information.""" + with devops_info_system_collection_seconds.time(): + system_info = { + 'hostname': socket.gethostname(), + 'platform': platform.system(), + 'platform_version': platform.version(), + 'architecture': platform.machine(), + 'cpu_count': os.cpu_count() or 0, + 'python_version': platform.python_version() + } + return system_info + + +def get_runtime_info() -> Dict[str, Any]: + """Collect runtime information.""" + return { + 'uptime_seconds': get_uptime()['seconds'], + 'uptime_human': get_uptime()['human'], + 'current_time': datetime.now().isoformat(), + 'timezone': 'UTC' + } + + +def get_service_info() -> Dict[str, Any]: + """Collect service information.""" + return { + 'name': 'devops-info-service', + 'version': '1.0.0', + 'description': 'DevOps course info service', + 'framework': 'FastAPI' + } + + +def get_request_info(request: Request) -> Dict[str, Any]: + """Collect information about the current request.""" + client_ip = request.client.host if request.client else "unknown" + user_agent = request.headers.get('user-agent', 'unknown') + return { + 'client_ip': client_ip, + 'user_agent': user_agent, + 'method': request.method, + 'path': request.url.path + } + + +def get_endpoints_list() -> list: + """Get list of available endpoints.""" + return [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + { + "path": "/metrics", + "method": "GET", + "description": "Prometheus metrics" + } + ] + + +@app.get("/metrics") +async def metrics(): + """Expose Prometheus metrics.""" + return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST) + + +@app.get("/", response_model=Dict[str, Any]) +async def get_service_information(request: Request) -> Dict[str, Any]: + """ + Main endpoint - returns service and system information. + """ + return { + "service": get_service_info(), + "system": get_system_info(), + "runtime": get_runtime_info(), + "request": get_request_info(request), + "endpoints": get_endpoints_list() + } + + +@app.get("/health", response_model=Dict[str, Any]) +async def health_check() -> Dict[str, Any]: + """ + Health check endpoint for monitoring and service discovery. + """ + return { + "status": "healthy", + "timestamp": datetime.now().isoformat(), + "uptime_seconds": get_uptime()['seconds'], + } + + +@app.exception_handler(404) +async def not_found_handler(request: Request, exc): + """Handle 404 Not Found errors.""" + client_ip = request.client.host if request.client else "unknown" + if "x-forwarded-for" in request.headers: + client_ip = request.headers["x-forwarded-for"].split(",")[0].strip() + + duration = None + if hasattr(request.state, "start_time"): + duration = round(time.time() - request.state.start_time, 4) + + logger.warning("Not found", extra={ + "method": request.method, + "path": request.url.path, + "client_ip": client_ip, + "status_code": 404, + "duration": duration + }) + + return JSONResponse( + status_code=404, + content={ + "error": "Not Found", + "message": f"Endpoint {request.url.path} does not exist", + "timestamp": datetime.now().isoformat() + } + ) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + """Handle all unhandled exceptions (including 500).""" + client_ip = request.client.host if request.client else "unknown" + if "x-forwarded-for" in request.headers: + client_ip = request.headers["x-forwarded-for"].split(",")[0].strip() + + duration = None + if hasattr(request.state, "start_time"): + duration = round(time.time() - request.state.start_time, 4) + + logger.error("Unhandled exception", exc_info=True, extra={ + "method": request.method, + "path": request.url.path, + "client_ip": client_ip, + "status_code": 500, + "duration": duration + }) + + return JSONResponse( + status_code=500, + content={ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + "timestamp": datetime.now().isoformat() + } + ) + + +if __name__ == "__main__": + import uvicorn + + logger.info("Starting server", extra={ + "event": "serve", + "host": HOST, + "port": PORT, + "debug": DEBUG + }) + + uvicorn.run( + app, + host=HOST, + port=PORT, + log_level="info" if DEBUG else "warning" + ) diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..3d0aea62a1 --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,141 @@ +## Framework Selection +I chose FastAPI because it is a modern standard, async and it is easy to use. + +| Criteria | FastAPI | Flask | Django | +|-------------|---------|-------|--------| +| Is standard | yes | no | yes | +| Async | yes | no | no | +| Simple | yes | yes | no | + +## Best Practices Applied +### Clean Code Organization +```python +# Grouped and ordered imports +import os +import socket +import platform +import logging +from datetime import datetime, timezone +from typing import Dict, Any + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware +``` +Importance: Proper import organization improves readability and avoids circular dependencies. + +### Separation of Concerns +```python +def get_system_info() -> Dict[str, Any]: + """Collect system information.""" + return { + 'hostname': socket.gethostname(), + 'platform': platform.system(), + # ... + } + +def get_runtime_info() -> Dict[str, Any]: + """Collect runtime information.""" + # ... +``` + +Importance: Each function has a single responsibility, making code easier to test, maintain, and reuse. + + +### Type Hints +```python +def get_uptime() -> Dict[str, Any]: + """Calculate application uptime since start.""" + # ... +``` +Importance: Improves code readability + +### Environment-Based Configuration + +```python +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', '5000')) +DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' +``` + +Importance: Allows configuration changes without code modifications + +### Logging +```python +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) +``` + +Importance: Provides operational monitoring + + +### Error Handling +```python +@app.exception_handler(404) +async def not_found_handler(request: Request, exc): + """Handle 404 Not Found errors.""" + return JSONResponse( + status_code=404, + content={ + "error": "Not Found", + "message": f"Endpoint {request.url.path} does not exist", + "timestamp": datetime.now().isoformat() + } + ) +``` +Importance: Provides clear error messages to users, prevents leakage of internal information + + +## API Documentation + +### GET / + +Request: curl -X GET "http://localhost:5000/" + +Response: +```json +{"service":{"name":"devops-info-service","version":"1.0.0","description":"DevOps course info service","framework":"FastAPI"},"system":{"hostname":"DESKTOP-2Q0E6TS","platform":"Windows","platform_version":"10.0.19045","architecture":"AMD64","cpu_count":8,"python_version":"3.11.5"},"runtime":{"uptime_seconds":163,"uptime_human":"0 hours, 2 minutes","current_time":"2026-01-25T10:46:35.373465","timezone":"UTC"},"request":{"client_ip":"127.0.0.1","user_agent":"PostmanRuntime/7.39.1","method":"GET","path":"/"},"endpoints":[{"path":"/","method":"GET","description":"Service information"},{"path":"/health","method":"GET","description":"Health check"}]} +``` +### GET /health + +Request: curl -X GET "http://localhost:5000/health" + +Response: +```json +{"status":"healthy","timestamp":"2026-01-25T10:48:49.392040","uptime_seconds":297} +``` + +## Testing Evidence + +Terminal Output +``` +2026-01-25 10:43:51,683 - __main__ - INFO - Starting Service on 0.0.0.0:5000 +2026-01-25 10:44:19,388 - app - INFO - GET / requested from 127.0.0.1 +2026-01-25 10:45:19,772 - app - INFO - GET / requested from 127.0.0.1 +2026-01-25 10:45:32,977 - app - INFO - GET / requested from 127.0.0.1 +2026-01-25 10:46:35,372 - app - INFO - GET / requested from 127.0.0.1 +2026-01-25 10:48:02,640 - app - INFO - GET / requested from 127.0.0.1 +``` + +## Challenges & Solutions +Challenge: Asynchronous Request Handling +Problem: Some Python functions (like socket.gethostname()) are blocking in async FastAPI context. + +Solution: Used them in synchronous functions within async endpoints. For production with high load, asyncio.to_thread() could be implemented. + + +## GitHub Community +### Importance of Repository Stars + +Quality Signal: Star count helps new users assess project reliability and activity. + +Community Support: Stars are a form of appreciation and support for maintainers, showing their work is valuable to the community. + +### Importance of Following Developers + +Networking: I can see what projects other course participants are working on, creating opportunities for collaboration and knowledge sharing. + +Learning by Example: Observing how experienced developers work teaches best practices, new tools, and problem-solving approaches. diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..fff3b90fba --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,144 @@ +## Docker Best Practices Applied +1. Non-Root User +```dockerfile +RUN useradd --create-home --shell /bin/bash appuser +USER appuser +``` +Why it matters: Running containers as non-root users reduces security risks if the container is compromised. + +2. Layer Caching +```dockerfile +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY app.py . +``` +Why it matters: Docker caches each layer. If requirements.txt hasn't changed, Docker uses cache, speeding up builds. + +3. Minimal Base Image +```dockerfile +FROM python:3.11.8-slim-bookworm +``` +Why it matters: The slim image is smaller than the full image, reducing attack surface and download time. + +## Image Information & Decisions + +1. Base Image +Chose python:3.11.8-slim-bookworm as a balance between size and functionality. + +2. Final image +263 MB + +3. Layer Structure +Python base image + +User creation + +Python dependencies installation + +Source code copy + +## Build & Run Process + +### Complete terminal output from the build process +docker build -t pythonapp:1.0.0 . + +```bash +[+] Building 2.1s (12/12) FINISHED docker:desktop-linux + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 303B 0.0s + => [internal] load metadata for docker.io/library/python:3.11.8-slim-bookworm 1.6s + => [auth] library/python:pull token for registry-1.docker.io 0.0s + => [internal] load .dockerignore 0.0s + => => transferring context: 152B 0.0s + => [1/6] FROM docker.io/library/python:3.11.8-slim-bookworm@sha256:90f8795536170fd08236d2ceb74fe7065dbf74f738d8b84bfbf263656654dc9b 0.0s + => => resolve docker.io/library/python:3.11.8-slim-bookworm@sha256:90f8795536170fd08236d2ceb74fe7065dbf74f738d8b84bfbf263656654dc9b 0.0s + => [internal] load build context 0.0s + => => transferring context: 64B 0.0s + => CACHED [2/6] RUN useradd --create-home --shell /bin/bash appuser 0.0s + => CACHED [3/6] WORKDIR /app 0.0s + => CACHED [4/6] COPY requirements.txt . 0.0s + => CACHED [5/6] RUN pip install --no-cache-dir -r requirements.txt 0.0s + => CACHED [6/6] COPY app.py . 0.0s + => exporting to image 0.2s + => => exporting layers 0.0s + => => exporting manifest sha256:ba351c3b5ef9c55d6620c190aeedf9cd4c55660ea671b91cc0bcd04efb6579d1 0.0s + => => exporting config sha256:ed7914d6951962b12adb087245dc2d62fa66131e3102546b0e0f019c58265967 0.0s + => => exporting attestation manifest sha256:f7afeb5728bc47c570b62f6860cb40aedd9e76562afb4c33e585100b9ff2d5c1 0.0s + => => exporting manifest list sha256:5a37812502df4a63d16ee75c53e9e7812d727d05ce9ccc7e1b7b5ad38222d0f5 0.0s + => => naming to docker.io/library/pythonapp:1.0.0 0.0s + => => unpacking to docker.io/library/pythonapp:1.0.0 0.0s +``` + +run -d -p 5000:5000 pythonapp:1.0.0 +4d6e4c3c34c98dbaa34a9d691b35687703e800e36490ed7916762dfcc611f6af + +docker ps +```bash +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +4d6e4c3c34c9 pythonapp:1.0.0 "python app.py" 3 minutes ago Up 3 minutes 0.0.0.0:5000->5000/tcp, [::]:5000->5000/tcp elastic_hermann +``` + +curl http://localhost:5000 +```bash +{"service":{"name":"devops-info-service","version":"1.0.0","description":"DevOps course info service","framework":"FastAPI"},"system":{"hostname":"4d6e4c3c34c9","platform":"Linux","platform_version":"#1 SMP Tue Nov 5 00:21:55 UTC 2024","architecture":"x86_64","cpu_count":8,"python_version":"3.11.8"},"runtime":{"uptime_seconds":49,"uptime_human":"0 hours, 0 minutes","current_time":"2026-02-04T09:44:00.615006","timezone":"UTC"},"request":{"client_ip":"172.17.0.1","user_agent":"curl/8.13.0","method":"GET","path":"/"},"endpoints":[{"path":"/","method":"GET","description":"Service information"},{"path":"/health","method":"GET","description":"Health check"}]} +``` + +curl http://localhost:5000/health +```bash +{"status":"healthy","timestamp":"2026-02-04T09:44:50.535310","uptime_seconds":99} +``` + +docker login +```bash +Authenticating with existing credentials... [Username: aidarsarvartdinov] + +i Info → To login with a different account, run 'docker logout' followed by 'docker login' + + +Login Succeeded +``` +docker tag pythonapp:1.0.0 aidarsarvartdinov/pythonapp:1.0.0 +docker push aidarsarvartdinov/pythonapp:1.0.0 + +```bash +The push refers to repository [docker.io/aidarsarvartdinov/pythonapp] +87b8bf94a2ac: Pushed +1103112ebfc4: Pushed +162e5e391d8e: Pushed +b4b80ef7128d: Pushed +e165a9131697: Pushed +cc7f04ac52f8: Pushed +2357907a9de6: Pushed +5b1866afe005: Pushed +feadaf5c4ba6: Pushed +06f372162f15: Pushed +8a1e25ce7c4f: Pushed +1.0.0: digest: sha256:ce016e6e2263bff54be5b138729d6d972d5d0d6e1e16165021c8c5ee2f5971bf size: 856 +``` + +Docker Hub URL: +https://hub.docker.com/layers/aidarsarvartdinov/pythonapp/1.0.0/images/sha256:ba351c3b5ef9c55d6620c190aeedf9cd4c55660ea671b91cc0bcd04efb6579d1?uuid=3A5462CF-AA93-4E88-AFD2-BDB1B602384A + + +## Technical Analysis +1. Why does this layer order work? +Order matters for caching. Dependencies change less frequently than code, so they're installed first. + +2. What if we change the layer order? +If code is copied before dependencies, every code change would trigger dependency reinstallation. + +3. Security Measures Implemented +Non-root user + +Minimal base image + +4. How .dockerignore Helps +Reduces build context, speeds up the process, and prevents secrets from entering the image. + +## Challenges & Solutions +What I Learned: +Importance of Docker layer caching + +Container security principles + +Image size optimization techniques diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..e3b4873bd1 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,100 @@ +## Testing +I chose pytest because it it simple and modern standard + +To run tests locally: + +After installing requirements: + +```bash +pytest +``` + +Output example: +```bash +================================================ test session starts ================================================= +platform win32 -- Python 3.11.5, pytest-9.0.2, pluggy-1.6.0 +rootdir: C:\Projects\DevOps\DevOps-Core-Course\app_python +plugins: anyio-4.12.1 +collected 4 items + +tests\test_app.py .... [100%] + +================================================= 4 passed in 0.50s ================================================== +``` + +## Workflow + +### Trigger Strategy + +The workflow triggers on every push to run tests and linting, ensuring code quality on each commit. + +Docker build and push is triggered only when a pull request is opened. This avoids unnecessary image builds for intermediate commits or documentation updates. + +### Actions + +actions/checkout@v4 – supports fetch-depth: 0 to retrieve Git tags for version detection. + +actions/setup-python@v4 – provides Python setup with built‑in pip caching. + +actions/cache@v4 - used for Python venv caching. + +docker/login-action@v3 – securely handles Docker Hub credentials via GitHub Secrets. + +docker/metadata-action@v5 – generating Docker tags and labels; automatically extracts SemVer from Git tags and adds latest. + +docker/setup-buildx-action@v3 – enables Buildx for efficient layer caching. + +docker/build-push-action@v5 – integrates caching, tag list, and push in one step. + +### Tagging Strategy + +latest – always updated on every new pull request; represents the most recent stable build. + +X.Y.Z (SemVer) – added only when the commit associated with the pull request has a Git tag vX.Y.Z; ensures exact versioning for releases. + +### Workflow run link +https://github.com/AidarSarvartdinov/DevOps-Core-Course/actions/runs/21944641464/job/63378950422 + + +### Output +docker-build-push job runs only on pull request, so it was skipped here + +![alt text](./screenshots/workflowoutput.png) + +## CI Best Practices & Security + +### Status Badge + +[![Python app - Test & Docker Push](https://github.com/AidarSarvartdinov/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg?event=pull_request)](https://github.com/AidarSarvartdinov/DevOps-Core-Course/actions/workflows/python-ci.yml) + +### Dependencies caching + +venv createion and dependencies installation took 11 seconds, total 27 seconds + +After caching the installation took 0 seconds, total 15 seconds + + +### Best Practices + +Matrix Builds: Test multiple Python versions (3.11, 3.12, 3.13). Ensures compatibility of the code with different versions of the interpreter + +Fail Fast: Stop workflow on first failure. Gives quick feedback + +Job Dependencies: Docker won't be pushed if tests fail + +Workflow Concurrency: Cancel outdated workflow runs + + +### Found vulnerabilities +```bash +Upgrade starlette@0.38.6 to starlette@0.49.1 to fix + ✗ Regular Expression Denial of Service (ReDoS) [High Severity][https://security.snyk.io/vuln/SNYK-PYTHON-STARLETTE-13733964] in starlette@0.38.6 + introduced by starlette@0.38.6 and 1 other path(s) + ✗ Allocation of Resources Without Limits or Throttling [High Severity][https://security.snyk.io/vuln/SNYK-PYTHON-STARLETTE-8186175] in starlette@0.38.6 + introduced by starlette@0.38.6 and 1 other path(s) +``` + +## Challenges +At first, snyk ran outside the virtual environment. I changed python/@master to setup/@master + +When I updated starlette, fastapi started requiring some older version, so I had to update fastapi. 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..47dfb85658 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..87caf7777f 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..a24c928a5a Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/app_python/docs/screenshots/workflowoutput.png b/app_python/docs/screenshots/workflowoutput.png new file mode 100644 index 0000000000..ac8c0a0ce1 Binary files /dev/null and b/app_python/docs/screenshots/workflowoutput.png differ diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..a9a245a32e Binary files /dev/null and b/app_python/requirements.txt differ diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/tests/test_app.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..55574a53f6 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,96 @@ +from datetime import datetime + +from fastapi.testclient import TestClient + +import app + +client = TestClient(app.app) + + +def is_iso8601(s: str) -> bool: + """Простая проверка, что строка парсится datetime.fromisoformat.""" + try: + datetime.fromisoformat(s) + return True + except Exception: + return False + + +def test_root_success_structure_and_types(): + resp = client.get("/") + assert resp.status_code == 200 + + data = resp.json() + for key in ("service", "system", "runtime", "request", "endpoints"): + assert key in data + + # service + svc = data["service"] + for k in ("name", "version", "description", "framework"): + assert k in svc and isinstance(svc[k], str) + + # system + system = data["system"] + assert "hostname" in system and isinstance(system["hostname"], str) + assert "platform" in system and isinstance(system["platform"], str) + assert "cpu_count" in system and isinstance(system["cpu_count"], int) + + # runtime + runtime = data["runtime"] + assert "uptime_seconds" in runtime and \ + isinstance(runtime["uptime_seconds"], int) + assert "uptime_human" in runtime and \ + isinstance(runtime["uptime_human"], str) + assert "current_time" in runtime and \ + is_iso8601(runtime["current_time"]) + + # request + req = data["request"] + assert "client_ip" in req + assert "user_agent" in req + assert req.get("method") == "GET" + assert req.get("path") == "/" + + # endpoints + endpoints = data["endpoints"] + paths = {e.get("path") for e in endpoints if isinstance(e, dict)} + assert "/" in paths + assert "/health" in paths + + +def test_health_success_and_types(): + resp = client.get("/health") + assert resp.status_code == 200 + + data = resp.json() + assert data.get("status") == "healthy" + assert "timestamp" in data and is_iso8601(data["timestamp"]) + assert "uptime_seconds" in data and \ + isinstance(data["uptime_seconds"], int) + + +def test_404_not_found_handler(): + resp = client.get("/endpoint-does-not-exist") + assert resp.status_code == 404 + + data = resp.json() + assert data.get("error") == "Not Found" + assert "message" in data + assert "timestamp" in data and is_iso8601(data["timestamp"]) + + +def test_internal_server_error_handler(monkeypatch): + def raise_exc(): + raise RuntimeError("simulated failure") + + monkeypatch.setattr(app, "get_service_info", raise_exc) + + client_no_raise = TestClient(app.app, raise_server_exceptions=False) + resp = client_no_raise.get("/") + + assert resp.status_code == 500 + + data = resp.json() + assert data.get("error") == "Internal Server Error" + assert data.get("message") == "An unexpected error occurred" + assert "timestamp" in data and is_iso8601(data["timestamp"]) diff --git a/docs/LAB04.md b/docs/LAB04.md new file mode 100644 index 0000000000..0ee7c34b2f --- /dev/null +++ b/docs/LAB04.md @@ -0,0 +1,437 @@ +## 1. Cloud Provider & Infrastructure + +**Provider:** Yandex Cloud +**Reason:** Accessible in Russia, has a free tier, and has a well-maintained Terraform provider. + +| Parameter | Value | +|-----------|-------| +| Instance type | `standard-v2`, 2 vCPU @ 20% (core_fraction), 1 GB RAM | +| Boot disk | 10 GB HDD (`network-hdd`) | +| OS | Ubuntu 24.04 LTS | +| Region / Zone | `ru-central1-a` | +| Estimated cost | $0 | + +**Resources created:** +- `yandex_vpc_network` — main VPC +- `yandex_vpc_subnet` — `10.0.0.0/24` +- `yandex_vpc_security_group` — SSH (22), HTTP (80), App (5000) +- `yandex_compute_instance` — VM with public IP (NAT) + +--- + +## 2. Terraform Implementation + +**Terraform version:** 1.14.5 +**Provider:** `yandex-cloud/yandex` + +### Project Structure + +``` +terraform/ +├── .gitignore # Excludes *.tfstate, .terraform/, *.tfvars +├── main.tf # Provider + all Yandex Cloud resources +├── variables.tf # Input variables (credentials, zone, SSH key, etc.) +├── outputs.tf # Public IP, SSH command, resource IDs +``` + +### Key Configuration Decisions + +- **Credentials via `terraform.tfvars`** (gitignored) — never hardcoded +- **`core_fraction = 20`** — Yandex Cloud free-tier burstable CPU +- **`nat = true`** on network interface — allocates public IP automatically +- **Security group restricts SSH** — `allowed_ssh_cidr` variable +- **Labels** on all resources for identification (`project`, `env`, `managed`) + +### Terminal Output + +#### `terraform init` +``` +Initializing the backend... +Initializing provider plugins... +- Reusing previous version of yandex-cloud/yandex from the dependency lock file +- Using previously-installed yandex-cloud/yandex v0.187.0 + +Terraform has been successfully initialized! + +You may now begin working with Terraform. Try running "terraform plan" to see +any changes that are required for your infrastructure. All Terraform commands +should now work. + +If you ever set or change modules or backend configuration for Terraform, +rerun this command to reinitialize your working directory. If you forget, other +commands will detect it and remind you to do so if necessary. +``` + +#### `terraform plan` +``` +data.yandex_vpc_network.default: Reading... +data.yandex_vpc_network.default: Read complete after 0s [id=enpae0tmmlssbd0q9k2v] + +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.main will be created + + resource "yandex_compute_instance" "main" { + + 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 = { + + "env" = "lab" + + "managed" = "terraform" + + "project" = "devops-lab04" + } + + maintenance_grace_period = (known after apply) + + maintenance_policy = (known after apply) + + metadata = { + + "ssh-keys" = <<-EOT + ubuntu:ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQClVyvp+ZD64NsWEjF4uEydy+Y7qD6Bfhb1Sv5L0hMTQHOkDJWIpPdnm1eKHp8fycACwDsryaY967MY7W493vEPYT/9gLYT74+YxHH73mfywHLkcgodOo9o1hLEPEsiXdd35HST+BtAG7lbDrUh2ZzcHiK48KhpU/6ZjxFhybuSC3l3MOifZ3oTOK5QIUMiqHshAvuTWZ1uJt+5KmMT9+douBHlAm4COdeVdEM0k8D8/t+MiR/PbJ31wSzAadsls0z6ZRb0P7530HAeGDluZSDHnlDWMdH5+byQw6+1UZWBy4EQdrFoQbWPfybAsIS4O14Nqxv86cFkWLaINpb0roOd aidar@DESKTOP-2Q0E6TS + EOT + } + + name = "devops-lab04-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 = "fd8ciuqfa001h8s9sa7i" + + 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) + } + + + placement_policy (known after apply) + + + resources { + + core_fraction = 20 + + cores = 2 + + memory = 1 + } + + + scheduling_policy (known after apply) + } + + # yandex_vpc_security_group.main will be created + + resource "yandex_vpc_security_group" "main" { + + created_at = (known after apply) + + folder_id = (known after apply) + + id = (known after apply) + + labels = (known after apply) + + name = "devops-lab04-sg" + + network_id = "enpae0tmmlssbd0q9k2v" + + status = (known after apply) + + + egress { + + 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", + ] + + v6_cidr_blocks = [] + # (3 unchanged attributes hidden) + } + + + ingress { + + description = "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", + ] + + v6_cidr_blocks = [] + # (2 unchanged attributes hidden) + } + + ingress { + + description = "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", + ] + + v6_cidr_blocks = [] + # (2 unchanged attributes hidden) + } + + ingress { + + description = "SSH access" + + 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", + ] + + v6_cidr_blocks = [] + # (2 unchanged attributes hidden) + } + } + + # yandex_vpc_subnet.main will be created + + resource "yandex_vpc_subnet" "main" { + + created_at = (known after apply) + + folder_id = (known after apply) + + id = (known after apply) + + labels = (known after apply) + + name = "devops-lab04-subnet" + + network_id = "enpae0tmmlssbd0q9k2v" + + v4_cidr_blocks = [ + + "10.0.0.0/24", + ] + + v6_cidr_blocks = (known after apply) + + zone = "ru-central1-a" + } + +Plan: 3 to add, 0 to change, 0 to destroy. + +Changes to Outputs: + + network_id = "enpae0tmmlssbd0q9k2v" + + security_group_id = (known after apply) + + ssh_connection_command = (known after apply) + + vm_id = (known after apply) + + vm_name = "devops-lab04-vm" + + vm_public_ip = (known after apply) + +``` + +#### `terraform apply` +``` +yandex_vpc_network.main: Creating... +yandex_vpc_network.main: Creation complete after 2s +yandex_vpc_subnet.main: Creating... +yandex_vpc_subnet.main: Creation complete after 1s +yandex_vpc_security_group.main: Creating... +yandex_vpc_security_group.main: Creation complete after 2s +yandex_compute_instance.main: Creating... +yandex_compute_instance.main: Still creating... [10s elapsed] +yandex_compute_instance.main: Creation complete after 25s +yandex_vpc_subnet.main: Creating... +yandex_vpc_security_group.main: Creating... +yandex_vpc_subnet.main: Creation complete after 1s [id=e9b3464grg4i0cu7k6a1] +yandex_vpc_security_group.main: Creation complete after 3s [id=enpuc5sudg1cenr6d3hi] +yandex_compute_instance.main: Creating... +yandex_compute_instance.main: Still creating... [00m10s elapsed] +yandex_compute_instance.main: Still creating... [00m20s elapsed] +yandex_compute_instance.main: Still creating... [00m30s elapsed] +yandex_compute_instance.main: Still creating... [00m40s elapsed] +yandex_compute_instance.main: Creation complete after 49s [id=fhmss6k7g89idtvruoal] + +Apply complete! Resources: 3 added, 0 changed, 0 destroyed. + +Outputs: + +network_id = "enpae0tmmlssbd0q9k2v" +security_group_id = "enpm41ddsk3oip11fs1a" +ssh_connection_command = "ssh ubuntu@93.77.180.64" +vm_id = "fhmbch4vkglvkd6i6tae" +vm_name = "devops-lab04-vm" +vm_public_ip = "93.77.180.64" +``` + +#### SSH Access +```bash +Welcome to Ubuntu 24.04.3 LTS (GNU/Linux 6.8.0-90-generic x86_64) + + * Documentation: https://help.ubuntu.com + * Management: https://landscape.canonical.com + * Support: https://ubuntu.com/pro + + System information as of Wed Feb 18 19:03:46 UTC 2026 + + System load: 0.25 Processes: 99 + Usage of /: 23.1% of 9.04GB Users logged in: 0 + Memory usage: 19% IPv4 address for eth0: 10.0.0.32 + Swap usage: 0% +``` + + +## 3. Pulumi Implementation + +**Pulumi version:** 3.221.0 +**Language:** Python +**Provider:** `pulumi-yandex` + +### Project Structure + +``` +pulumi/ +├── .gitignore # Excludes venv/, __pycache__/, Pulumi.*.yaml +├── Pulumi.yaml # Project metadata +├── requirements.txt +└── __main__.py # All infrastructure in Python +``` + +### How Code Differs from Terraform + +| Aspect | Terraform (HCL) | Pulumi (Python) | +|--------|-----------------|-----------------| +| Language | Declarative HCL | Imperative Python | +| Resources | `resource "type" "name" {}` | `Type("name", TypeArgs(...))` | +| Variables | `var.name` | `config.require("name")` | +| Outputs | `output "x" { value = ... }` | `pulumi.export("x", value)` | +| Secrets | Plain in tfvars | `config.require_secret()` — encrypted | +| Logic | Limited (count, for_each) | Full Python (loops, functions, classes) | + + + +### Terminal Output + +#### `pulumi preview` +``` +Enter your passphrase to unlock config/secrets + (set PULUMI_CONFIG_PASSPHRASE or PULUMI_CONFIG_PASSPHRASE_FILE to remember): +Enter your passphrase to unlock config/secrets +Previewing update (dev): + Type Name Plan + + pulumi:pulumi:Stack devops-lab04-pulumi-dev create + + ├─ yandex:index:VpcSubnet devops-lab04-subnet create + + ├─ yandex:index:VpcSecurityGroup devops-lab04-sg create + + └─ yandex:index:ComputeInstance devops-lab04-vm create +Outputs: + network_id : "enpae0tmmlssbd0q9k2v" + public_ip : [unknown] + security_group_id : [unknown] + ssh_connection_command: [unknown] + vm_id : [unknown] + vm_name : "devops-lab04-vm" + +Resources: + + 4 to create +``` + +#### `pulumi up` +``` +Updating (dev): + Type Name Status + + pulumi:pulumi:Stack devops-lab04-pulumi-dev created (52s) + + ├─ yandex:index:VpcSubnet devops-lab04-subnet created (0.78s) + + ├─ yandex:index:VpcSecurityGroup devops-lab04-sg created (2s) + + └─ yandex:index:ComputeInstance devops-lab04-vm created (47s) +Outputs: + network_id : "enpae0tmmlssbd0q9k2v" + public_ip : "89.169.137.6" + security_group_id : "enp5ouie5q42mfrdhbi6" + ssh_connection_command: "ssh ubuntu@89.169.137.6" + vm_id : "fhmfn6pk0aqll45ae3ac" + vm_name : "devops-lab04-vm" + +Resources: + + 4 created + +Duration: 54s +``` + +#### SSH Access +```bash +ssh ubuntu@89.169.137.6 + +Welcome to Ubuntu 24.04.3 LTS (GNU/Linux 6.8.0-90-generic x86_64) + + * Documentation: https://help.ubuntu.com + * Management: https://landscape.canonical.com + * Support: https://ubuntu.com/pro + + System information as of Thu Feb 19 10:07:53 UTC 2026 + + System load: 0.14 Processes: 99 + Usage of /: 23.1% of 9.04GB Users logged in: 0 + Memory usage: 17% IPv4 address for eth0: 10.0.0.28 + Swap usage: 0% +``` + +--- + +## 4. Terraform vs Pulumi Comparison + +### Ease of Learning +**Terraform** is easier to learn for infrastructure beginners — HCL is simple, purpose-built, and has abundant tutorials. **Pulumi** requires knowing a programming language first, but if you already know Python it feels very natural. For this lab, Terraform had a shallower learning curve. + +### Code Readability +**Terraform** HCL is very readable for infrastructure — the declarative style makes it clear what resources exist. **Pulumi** Python is more verbose but gains readability through type hints, IDE autocomplete, and the ability to extract helper functions. For simple infrastructure, Terraform wins; for complex logic, Pulumi wins. + +### Debugging +**Pulumi** is easier to debug — you get Python stack traces, can add `print()` statements, and use a debugger. **Terraform** errors are sometimes cryptic, and you can't easily add debugging logic to HCL. + +### Documentation +**Terraform** has better documentation and more community examples. The Terraform Registry is comprehensive. **Pulumi** docs are good but the Yandex provider specifically has fewer examples. + +### Use Case +- **Terraform:** Standard infrastructure provisioning, team environments, when HCL's simplicity is a feature, brownfield imports +- **Pulumi:** Complex infrastructure with conditional logic, when you want to reuse existing code/libraries, when native testing matters, when secrets management is critical + +**Preference:** Terraform for straightforward infrastructure; Pulumi for complex, programmatic infrastructure. + + +## 5. Lab 5 Preparation & Cleanup + +**VM for Lab 5:** Yes, keeping the Pulumi-created VM running. + +**VM Details:** +- IP: `89.169.137.6` +- SSH: `ssh ubuntu@89.169.137.6` +- OS: Ubuntu 24.04 LTS +- Managed by: Pulumi (`pulumi/` directory) + +**Cleanup Status:** +- Terraform resources: **destroyed** +- Pulumi VM: **running** (kept for Lab 5 Ansible) + + +**Terraform destroy output:** +```bash +yandex_compute_instance.main: Destroying... +yandex_compute_instance.main: Destruction complete after 15s +yandex_vpc_security_group.main: Destroying... +yandex_vpc_security_group.main: Destruction complete after 3s +yandex_vpc_subnet.main: Destroying... +yandex_vpc_subnet.main: Destruction complete after 2s +yandex_vpc_network.main: Destroying... +yandex_vpc_network.main: Destruction complete after 1s + +Destroy complete! Resources: 4 destroyed. +``` diff --git a/k8s/LAB09.md b/k8s/LAB09.md new file mode 100644 index 0000000000..3ede92a58a --- /dev/null +++ b/k8s/LAB09.md @@ -0,0 +1,186 @@ +# Lab 09: Kubernetes Fundamentals + +## 1. Architecture Overview + +``` + ┌──────────────────────────────────┐ + │ Minikube Cluster │ + │ │ + │ ┌──────────────────────────┐ │ + │ │ Deployment: pythonapp │ │ + │ │ replicas: 3 (→5) │ │ + │ │ │ │ + │ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │ +User ──► NodePort │ │ │Pod 1│ │Pod 2│ │Pod 3│ │ │ + :30080 ──────►│ │ └─────┘ └─────┘ └─────┘ │ │ + │ └──────────────────────────┘ │ + │ │ + │ Service: pythonapp-service │ + │ Type: NodePort (80 → 5000) │ + └──────────────────────────────────┘ +``` + +- **3 replicas** of `aidarsarvartdinov/pythonapp:latest`, scalable to 5 +- **NodePort Service** on port 30080 forwards to container port 5000 +- **Resource limits**: 200m CPU / 256Mi memory per pod +- **Health checks**: liveness and readiness probes on `/health` + +## 2. Manifest Files + +### `k8s/deployment.yml` +- **Image**: `aidarsarvartdinov/pythonapp:latest` +- **Replicas**: 3 (chosen for availability while fitting minikube resources) +- **Strategy**: RollingUpdate with `maxSurge: 1, maxUnavailable: 0` for zero-downtime deployments +- **Resources**: requests 100m/128Mi, limits 200m/256Mi — lightweight Python app doesn't need much +- **Probes**: HTTP GET `/health` on port 5000 for both liveness (10s delay) and readiness (5s delay) + +### `k8s/service.yml` +- **Type**: NodePort — allows external access from host machine without a cloud load balancer +- **Port mapping**: 80 (service) → 5000 (container), NodePort 30080 + +## 3. Deployment Evidence + +### Cluster Setup +``` +$ minikube start --driver=docker +😄 minikube v1.38.1 on Microsoft Windows 10 Pro 22H2 +✨ Using the docker driver based on user configuration + +$ kubectl cluster-info +Kubernetes control plane is running at https://127.0.0.1:62261 +CoreDNS is running at https://127.0.0.1:62261/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy + +$ kubectl get nodes +NAME STATUS ROLES AGE VERSION +minikube Ready control-plane 9s v1.35.1 +``` + +Tool choice: **minikube** with Docker + +### Apply Manifests +``` +$ kubectl apply -f k8s/deployment.yml +deployment.apps/pythonapp created + +$ kubectl apply -f k8s/service.yml +service/pythonapp-service created +``` + +### Running Pods (3 replicas) +``` +$ kubectl get pods +NAME READY STATUS RESTARTS AGE +pythonapp-66f558849-9nt5d 1/1 Running 0 58s +pythonapp-66f558849-fmkdz 1/1 Running 0 58s +pythonapp-66f558849-wr7b7 1/1 Running 0 58s +``` + +### Services +``` +$ kubectl get svc +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +kubernetes ClusterIP 10.96.0.1 443/TCP 65s +pythonapp-service NodePort 10.105.195.156 80:30080/TCP 58s +``` + +### kubectl get all +``` +$ kubectl get all +NAME READY STATUS RESTARTS AGE +pod/pythonapp-66f558849-9nt5d 1/1 Running 0 59s +pod/pythonapp-66f558849-fmkdz 1/1 Running 0 59s +pod/pythonapp-66f558849-wr7b7 1/1 Running 0 59s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/kubernetes ClusterIP 10.96.0.1 443/TCP 66s +service/pythonapp-service NodePort 10.105.195.156 80:30080/TCP 59s + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/pythonapp 3/3 3 3 59s + +NAME DESIRED CURRENT READY AGE +replicaset.apps/pythonapp-66f558849 3 3 3 59s +``` + +### Describe Deployment +``` +$ kubectl describe deployment pythonapp +Name: pythonapp +Namespace: default +Replicas: 5 desired | 5 updated | 5 available | 0 unavailable +StrategyType: RollingUpdate +RollingUpdateStrategy: 0 max unavailable, 1 max surge +Pod Template: + Containers: + pythonapp: + Image: aidarsarvartdinov/pythonapp:latest + Port: 5000/TCP + Limits: cpu: 200m, memory: 256Mi + Requests: cpu: 100m, memory: 128Mi + Liveness: http-get http://:5000/health delay=10s timeout=1s period=5s + Readiness: http-get http://:5000/health delay=5s timeout=1s period=3s + Environment: + HOST: 0.0.0.0 + PORT: 5000 +``` + +## 4. Operations Performed + +### Scaling to 5 Replicas +``` +$ kubectl scale deployment/pythonapp --replicas=5 +deployment.apps/pythonapp scaled + +$ kubectl get pods +NAME READY STATUS RESTARTS AGE +pythonapp-66f558849-6fhtm 1/1 Running 0 14s +pythonapp-66f558849-9nt5d 1/1 Running 0 73s +pythonapp-66f558849-fmkdz 1/1 Running 0 73s +pythonapp-66f558849-ggp6l 1/1 Running 0 14s +pythonapp-66f558849-wr7b7 1/1 Running 0 73s +``` +All 5 replicas running — 2 new pods created in ~14s. + +### Rolling Update +``` +$ kubectl set env deployment/pythonapp APP_VERSION=v2 +deployment.apps/pythonapp env updated + +$ kubectl rollout status deployment/pythonapp +Waiting for deployment "pythonapp" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "pythonapp" rollout to finish: 2 out of 5 new replicas have been updated... +... +deployment "pythonapp" successfully rolled out +``` +Pods replaced one by one (maxSurge=1, maxUnavailable=0 → zero downtime). + +### Rollback +``` +$ kubectl rollout undo deployment/pythonapp +deployment.apps/pythonapp rolled back + +$ kubectl rollout status deployment/pythonapp +deployment "pythonapp" successfully rolled out + +$ kubectl rollout history deployment/pythonapp +REVISION CHANGE-CAUSE +3 +4 +``` + +### Service Access +``` +$ minikube service pythonapp-service --url +http://127.0.0.1: +``` +Or via port-forward: +``` +$ kubectl port-forward service/pythonapp-service 8080:80 +``` + +## 5. Production Considerations + +- **Health checks**: Liveness probe restarts unhealthy containers; readiness probe removes from service during startup. Both use the `/health` endpoint. +- **Resource limits**: Prevent a single pod from consuming all node resources. Requests ensure pods are scheduled on nodes with enough capacity. +- **Rolling updates**: `maxUnavailable: 0` ensures all pods remain available during deployments. +- **Improvements for prod**: Add PodDisruptionBudgets, network policies, Horizontal Pod Autoscaler, and external Ingress with TLS. diff --git a/k8s/deployment.yml b/k8s/deployment.yml new file mode 100644 index 0000000000..0f76a0fa11 --- /dev/null +++ b/k8s/deployment.yml @@ -0,0 +1,50 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pythonapp + labels: + app: pythonapp +spec: + replicas: 3 + selector: + matchLabels: + app: pythonapp + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app: pythonapp + spec: + containers: + - name: pythonapp + image: aidarsarvartdinov/pythonapp:latest + ports: + - containerPort: 5000 + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "200m" + livenessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 10 + periodSeconds: 5 + readinessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 5 + periodSeconds: 3 + env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "5000" diff --git a/k8s/service.yml b/k8s/service.yml new file mode 100644 index 0000000000..9146d9fce6 --- /dev/null +++ b/k8s/service.yml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: pythonapp-service + labels: + app: pythonapp +spec: + type: NodePort + selector: + app: pythonapp + ports: + - protocol: TCP + port: 80 + targetPort: 5000 + nodePort: 30080 diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 0000000000..85dfcd624b --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,129 @@ +services: + loki: + image: grafana/loki:3.0.0 + ports: + - "3100:3100" + command: -config.file=/etc/loki/config.yml + volumes: + - ./loki/config.yml:/etc/loki/config.yml:ro + - loki-data:/loki + networks: + - logging + deploy: + resources: + limits: + cpus: '1.0' + memory: 1G + reservations: + cpus: '0.5' + memory: 512M + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + promtail: + image: grafana/promtail:3.0.0 + ports: + - "9080:9080" + command: -config.file=/etc/promtail/config.yml + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + networks: + - logging + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 128M + + grafana: + image: grafana/grafana:11.3.0 + ports: + - "3000:3000" + env_file: + - .env + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/etc/grafana/dashboards:ro + networks: + - logging + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.2' + memory: 512M + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + + prometheus: + image: prom/prometheus:v3.9.0 + ports: + - "9090:9090" + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.retention.time=15d' + - '--storage.tsdb.retention.size=10GB' + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + networks: + - logging + deploy: + resources: + limits: + cpus: '1.0' + memory: 1G + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9090/-/healthy || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + + app-python: + build: + context: ../app_python + image: aidarsarvartdinov/pythonapp:latest + ports: + - "8000:5000" + networks: + - logging + labels: + logging: "promtail" + app: "pythonapp" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 128M + +volumes: + loki-data: + grafana-data: + prometheus-data: + +networks: + logging: diff --git a/monitoring/docs/LAB07.md b/monitoring/docs/LAB07.md new file mode 100644 index 0000000000..ff16e39b24 --- /dev/null +++ b/monitoring/docs/LAB07.md @@ -0,0 +1,123 @@ +# Lab 07 - Observability & Logging with Loki Stack + +## 1. Architecture + +```mermaid +graph TD + A[app-python (FastAPI)] -->|Logs via Docker Socket| B(Promtail) + B -->|LogQL HTTP Push| D[(Loki - TSDB Storage)] + E[Grafana] -->|LogQL Queries| D +``` + +- **Promtail** runs as an agent discovering Docker containers via the `/var/run/docker.sock`, scraping their logs and pushing them to Loki. +- **Loki** acts as the log aggregation system, indexing labels and storing raw log data efficiently using its TSDB backend. +- **Grafana** connects to Loki to visualize the logs and extract metrics. + +## 2. Setup Guide + +1. Navigate to the `monitoring` directory. +2. Initialize the `.env` file with Grafana admin password: + ```env + GF_AUTH_ANONYMOUS_ENABLED=false + GF_SECURITY_ADMIN_PASSWORD=admin + ``` +3. Deploy the stack: + ```bash + docker compose up -d + ``` +4. Access Grafana at `http://localhost:3000`. + +## 3. Configuration + +### Loki +Configured to use **schema v13** with **tsdb** index type and local `filesystem` storage. We enabled `retention_period: 168h` (7 days) and proper cleanup via the compactor. The `delete_request_store` was also explicitly set to `filesystem` for compatibility with retention configuration in Loki 3.0+. + +### Promtail +Configured to fetch logs directly from Docker containers via `docker_sd_configs` pointing to `unix:///var/run/docker.sock`. We added a relabeling rule to extract `__meta_docker_container_name` to define the target `container` label: + +```yaml + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' +``` + +## 4. Application Logging + +The `app-python` application utilizes `python-json-logger` to format Python's standard logging straight into JSON: + +```python +from pythonjsonlogger import jsonlogger + + +logHandler = logging.StreamHandler() +formatter = jsonlogger.JsonFormatter( + fmt='%(asctime)s %(levelname)s %(message)s', + datefmt='%Y-%m-%dT%H:%M:%S%z', + rename_fields={ + 'asctime': 'timestamp', + 'levelname': 'level' + } +) +logHandler.setFormatter(formatter) +logging.basicConfig(level=logging.INFO, handlers=[logHandler]) +logger = logging.getLogger(__name__) +``` +This enables parsing `level` or `message` variables dynamically in LogQL queries + +**JSON Log Output from App:** +![JSON Log Output](images/app_json.png) + +## 5. Dashboard + +The Grafana Dashboard features the following 4 panels: +1. **Panel Title**: `{app="pythonapp"}` showing a stream visualization of recent logs from the containers. +2. **Logs per second**: `sum by (app) (rate({app="pythonapp"}[1m]))` visualizing logs per second across apps. +3. **Errors**: `{app="pythonapp"} | json | level="ERROR"` or `|="ERROR"` showing specifically high-severity events in table format. +4. **Logs count**: `sum by (level) (count_over_time({app="pythonapp"} | json [5m]))` as a Pie Chart highlighting info vs errors. + +**LogQL Query Evidence:** +![Logs from all containers {job="docker"}](images/explore_docker.png) +![Python App Logs {app="pythonapp"}](images/explore_python.png) +![Python App Logs Error {app="pythonapp"} |= "devops"](images/explore_python_error.png) + +**Dashboard Verification:** +![Grafana Dashboard](images/dashboard.png) + +## 6. Production Config + +- **Resource Limits**: Applied `cpus` and `memory` limits to `loki`, `promtail`, `grafana` and both applications in `docker-compose.yml` using `deploy.resources.limits`. +- **Security**: Disabled anonymous authentication in Grafana (`GF_AUTH_ANONYMOUS_ENABLED=false`). + ![Grafana Login Screen](images/grafana_login.png) +- **Healthchecks**: Configured the apps and Grafana/Loki to support proper startup periods via Docker's embedded `healthcheck:` mechanism leveraging `wget`. + +## 7. Testing + +Health check: +```bash +curl http://localhost:8000/health +{"status":"healthy","timestamp":"2026-03-12T14:12:42.136853","uptime_seconds":984} +``` + +```bash +docker compose ps + +NAME IMAGE COMMAND SERVICE CREATED +STATUS PORTS +monitoring-app-python-1 aidarsarvartdinov/pythonapp:latest "python app.py" app-python 14 minutes ago +Up 14 minutes 0.0.0.0:8000->5000/tcp, [::]:8000->5000/tcp +monitoring-grafana-1 grafana/grafana:11.3.0 "/run.sh" grafana 14 minutes ago +Up 14 minutes (healthy) 0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp +monitoring-loki-1 grafana/loki:3.0.0 "/usr/bin/loki -conf…" loki 14 minutes ago +Up 14 minutes (healthy) 0.0.0.0:3100->3100/tcp, [::]:3100->3100/tcp +monitoring-promtail-1 grafana/promtail:3.0.0 "/usr/bin/promtail -…" promtail 14 minutes ago +Up 14 minutes 0.0.0.0:9080->9080/tcp, [::]:9080->9080/tcp +``` + +Verify the Grafana and Loki availability: +```bash +curl http://localhost:3100/ready + +ready +``` + diff --git a/monitoring/docs/LAB08.md b/monitoring/docs/LAB08.md new file mode 100644 index 0000000000..dcf0394267 --- /dev/null +++ b/monitoring/docs/LAB08.md @@ -0,0 +1,51 @@ +# Lab 08: Metrics & Monitoring with Prometheus + +## 1. Architecture +The monitoring stack now incorporates Prometheus for metrics alongside Loki for logs. +- **Python App**: Exposes prometheus metrics at `/metrics` using `prometheus_client`. +- **Prometheus**: Scrapes `/metrics` from the app, Loki, Grafana, and itself every 15s. Stores data persistently in `prometheus-data`. +- **Grafana**: Queries Prometheus to visualize application metrics via dashboards. + +## 2. Application Instrumentation +- **Counter (`http_requests_total`)**: Tracks request counts by `method`, `endpoint`, and `status`. +- **Histogram (`http_request_duration_seconds`)**: Measures request latency distribution for service-level objectives (p95). +- **Gauge (`http_requests_in_progress`)**: Monitors concurrent active requests. +- **Custom Histogram (`devops_info_system_collection_seconds`)**: Application-specific metric tracking time spent collecting system information. + +## 3. Prometheus Configuration +- **Scrape Interval**: 15 seconds. +- **Targets**: `prometheus:9090`, `app-python:8000`, `loki:3100`, `grafana:3000`. +- **Data Retention**: Configured for 15 days or 10GB max size via command flags. + +## 4. Dashboard Walkthrough +The `App Metrics` dashboard utilizes the RED method: +- **Request Rate**: Requests per second per endpoint. +- **Error Rate**: Rate of 5xx server errors. +- **Request Duration p95**: 95th percentile latency distribution. +- **Request Duration Heatmap**: Visual representation of latency distribution. +- **Active Requests**: Current requests being processed. +- **Status Code Distribution**: Pie chart illustrating response ratios. +- **Uptime**: Boolean status indicating app reachability. + +## 5. PromQL Examples +1. **Total Request Rate by Endpoint**: `sum(rate(http_requests_total[5m])) by (endpoint)` - Shows RPS across the app grouped by endpoints. +2. **Error Request Rate**: `sum(rate(http_requests_total{status=~"5.."}[5m]))` - Tracks internal server errors per second. +3. **95th Percentile Latency**: `histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))` - Indicates latency experienced by 95% of users. +4. **App Uptime**: `up{job="app"}` - Checks if Prometheus was able to scrape the app. +5. **System Info Generation Duration**: `rate(devops_info_system_collection_seconds_sum[5m]) / rate(devops_info_system_collection_seconds_count[5m])` - Average time spent purely collecting system metadata. + +## 6. Production Setup +- **Health Checks**: Implemented for Prometheus (`/-/healthy`) and the App (`/health`). +- **Resource Limits**: Applied logic limits (`cpus: 1.0`, `1G` RAM for Prometheus/Loki; `0.5`, `256M`/`512M` for app/grafana). +- **Persistence**: All data stored securely in named volumes (`prometheus-data`, `loki-data`, `grafana-data`). + +## 7. Metrics vs Logs +- **Logs (Loki)**: Provide rich context, exact errors, and stack traces. Use for debugging specific incidents. +- **Metrics (Prometheus)**: Aggregated numbers over time. Less storage intensive. Use for alerting, overall health trends, and broad SLA monitoring. + +## 8. Testing Results +### Prometheus Targets Status +![Prometheus Targets](./images/prometheus_targets.png) + +### Grafana App Metrics Dashboard +![Grafana Dashboard](./images/all_panels.png) diff --git a/monitoring/docs/images/all_panels.png b/monitoring/docs/images/all_panels.png new file mode 100644 index 0000000000..92e1c5dda4 Binary files /dev/null and b/monitoring/docs/images/all_panels.png differ diff --git a/monitoring/docs/images/app_json.png b/monitoring/docs/images/app_json.png new file mode 100644 index 0000000000..5b8d91427a Binary files /dev/null and b/monitoring/docs/images/app_json.png differ diff --git a/monitoring/docs/images/dashboard.png b/monitoring/docs/images/dashboard.png new file mode 100644 index 0000000000..b701d3d2fb Binary files /dev/null and b/monitoring/docs/images/dashboard.png differ diff --git a/monitoring/docs/images/explore_docker.png b/monitoring/docs/images/explore_docker.png new file mode 100644 index 0000000000..5ed6bdcf28 Binary files /dev/null and b/monitoring/docs/images/explore_docker.png differ diff --git a/monitoring/docs/images/explore_python.png b/monitoring/docs/images/explore_python.png new file mode 100644 index 0000000000..f1b595bbd0 Binary files /dev/null and b/monitoring/docs/images/explore_python.png differ diff --git a/monitoring/docs/images/explore_python_error.png b/monitoring/docs/images/explore_python_error.png new file mode 100644 index 0000000000..1879d6d3e3 Binary files /dev/null and b/monitoring/docs/images/explore_python_error.png differ diff --git a/monitoring/docs/images/grafana_login.png b/monitoring/docs/images/grafana_login.png new file mode 100644 index 0000000000..94c1100857 Binary files /dev/null and b/monitoring/docs/images/grafana_login.png differ diff --git a/monitoring/docs/images/prometheus_targets.png b/monitoring/docs/images/prometheus_targets.png new file mode 100644 index 0000000000..3bd798d27e Binary files /dev/null and b/monitoring/docs/images/prometheus_targets.png differ diff --git a/monitoring/gen_dashboard.py b/monitoring/gen_dashboard.py new file mode 100644 index 0000000000..b1243b834f --- /dev/null +++ b/monitoring/gen_dashboard.py @@ -0,0 +1,36 @@ +import json + +panels = [] +def add_panel(id, title, ptype, gridPos, targets, options=None): + p = { + "id": id, + "title": title, + "type": ptype, + "gridPos": gridPos, + "datasource": {"type": "prometheus", "uid": "Prometheus"}, + "targets": [{"expr": e, "refId": chr(65+i)} for i, e in enumerate(targets)] + } + if options: + p.update(options) + panels.append(p) + +add_panel(1, "Request Rate", "timeseries", {"h": 8, "w": 12, "x": 0, "y": 0}, ["sum(rate(http_requests_total[5m])) by (endpoint)"]) +add_panel(2, "Error Rate", "timeseries", {"h": 8, "w": 12, "x": 12, "y": 0}, ["sum(rate(http_requests_total{status=~'5..'}[5m]))"]) +add_panel(3, "Request Duration p95", "timeseries", {"h": 8, "w": 12, "x": 0, "y": 8}, ["histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))"]) +add_panel(4, "Request Duration Heatmap", "heatmap", {"h": 8, "w": 12, "x": 12, "y": 8}, ["sum(rate(http_request_duration_seconds_bucket[5m])) by (le)"], {"options": {"calculate": False}, "color": {"mode": "scheme"}}) +add_panel(5, "Active Requests", "stat", {"h": 8, "w": 8, "x": 0, "y": 16}, ["http_requests_in_progress"]) +add_panel(6, "Status Code Distribution", "piechart", {"h": 8, "w": 8, "x": 8, "y": 16}, ["sum by (status) (rate(http_requests_total[5m]))"]) +add_panel(7, "Uptime", "stat", {"h": 8, "w": 8, "x": 16, "y": 16}, ["up{job='app'}"]) + +dashboard = { + "title": "App Metrics", + "uid": "app_metrics", + "schemaVersion": 39, + "panels": panels, + "timezone": "browser", + "refresh": "5s", + "time": {"from": "now-1h", "to": "now"} +} + +with open(r"c:\Projects\DevOps\DevOps-Core-Course\monitoring\grafana\dashboards\app_metrics.json", "w") as f: + json.dump(dashboard, f, indent=2) diff --git a/monitoring/grafana/dashboards/app_metrics.json b/monitoring/grafana/dashboards/app_metrics.json new file mode 100644 index 0000000000..e706397e99 --- /dev/null +++ b/monitoring/grafana/dashboards/app_metrics.json @@ -0,0 +1,166 @@ +{ + "title": "App Metrics", + "uid": "app_metrics", + "schemaVersion": 39, + "panels": [ + { + "id": 1, + "title": "Request Rate", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "targets": [ + { + "expr": "sum(rate(http_requests_total[5m])) by (endpoint)", + "refId": "A" + } + ] + }, + { + "id": 2, + "title": "Error Rate", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "targets": [ + { + "expr": "sum(rate(http_requests_total{status=~'5..'}[5m]))", + "refId": "A" + } + ] + }, + { + "id": 3, + "title": "Request Duration p95", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))", + "refId": "A" + } + ] + }, + { + "id": 4, + "title": "Request Duration Heatmap", + "type": "heatmap", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "targets": [ + { + "expr": "sum(rate(http_request_duration_seconds_bucket[5m])) by (le)", + "refId": "A" + } + ], + "options": { + "calculate": false + }, + "color": { + "mode": "scheme" + } + }, + { + "id": 5, + "title": "Active Requests", + "type": "stat", + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 16 + }, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "targets": [ + { + "expr": "http_requests_in_progress", + "refId": "A" + } + ] + }, + { + "id": 6, + "title": "Status Code Distribution", + "type": "piechart", + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 16 + }, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "targets": [ + { + "expr": "sum by (status) (rate(http_requests_total[5m]))", + "refId": "A" + } + ] + }, + { + "id": 7, + "title": "Uptime", + "type": "stat", + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 16 + }, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "targets": [ + { + "expr": "up{job='app'}", + "refId": "A" + } + ] + } + ], + "timezone": "browser", + "refresh": "5s", + "time": { + "from": "now-1h", + "to": "now" + } +} \ No newline at end of file diff --git a/monitoring/grafana/provisioning/dashboards/provider.yml b/monitoring/grafana/provisioning/dashboards/provider.yml new file mode 100644 index 0000000000..86bd2b2395 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/provider.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: 'Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + options: + path: /etc/grafana/dashboards diff --git a/monitoring/grafana/provisioning/datasources/prometheus.yml b/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000000..b16c7dc2c5 --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,8 @@ +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: false + uid: Prometheus diff --git a/monitoring/loki/config.yml b/monitoring/loki/config.yml new file mode 100644 index 0000000000..5de07fa2de --- /dev/null +++ b/monitoring/loki/config.yml @@ -0,0 +1,37 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2020-10-24 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +storage_config: + filesystem: + directory: /loki/chunks + +limits_config: + retention_period: 168h + +compactor: + working_directory: /loki/boltdb-shipper-compactor + delete_request_store: filesystem + retention_enabled: true diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000000..d1cea55988 --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,22 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'app' + static_configs: + - targets: ['app-python:5000'] + metrics_path: '/metrics' + + - job_name: 'loki' + static_configs: + - targets: ['loki:3100'] + metrics_path: '/metrics' + + - job_name: 'grafana' + static_configs: + - targets: ['grafana:3000'] + metrics_path: '/metrics' diff --git a/monitoring/promtail/config.yml b/monitoring/promtail/config.yml new file mode 100644 index 0000000000..de0c991a0c --- /dev/null +++ b/monitoring/promtail/config.yml @@ -0,0 +1,23 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_label_app'] + target_label: 'app' + - target_label: 'job' + replacement: 'docker' diff --git a/pulumi/.gitignore b/pulumi/.gitignore new file mode 100644 index 0000000000..058593edf3 --- /dev/null +++ b/pulumi/.gitignore @@ -0,0 +1,6 @@ +venv/ +__pycache__/ +*.pyc +.pulumi/ + +Pulumi.*.yaml diff --git a/pulumi/Pulumi.yaml b/pulumi/Pulumi.yaml new file mode 100644 index 0000000000..465a9916c4 --- /dev/null +++ b/pulumi/Pulumi.yaml @@ -0,0 +1,3 @@ +name: devops-lab04-pulumi +runtime: python +description: Lab 04 — Yandex Cloud VM provisioning with Pulumi diff --git a/pulumi/__main__.py b/pulumi/__main__.py new file mode 100644 index 0000000000..5d1a7b95f1 --- /dev/null +++ b/pulumi/__main__.py @@ -0,0 +1,112 @@ +import pulumi +import pulumi_yandex as yandex +import os + + +config = pulumi.Config() +project_name = config.get("projectName") or "devops-lab04" +zone = config.get("zone") or "ru-central1-a" +folder_id = config.require("folderId") +ssh_user = config.get("sshUser") or "ubuntu" +ssh_public_key = config.require_secret("sshPublicKey") +image_id = config.get("imageId") or "fd83ica41cade1mj35sr" # Ubuntu 24.04 LTS v20251222 +allowed_ssh_cidr = config.get("allowedSshCidr") or "0.0.0.0/0" +token = config.get_secret("ycToken") or pulumi.Output.from_input(os.environ.get("YC_TOKEN", "")) + + +# Network — use existing default network (free tier quota: 1 network) +network = yandex.get_vpc_network(name="default", folder_id=folder_id) + +subnet = yandex.VpcSubnet( + f"{project_name}-subnet", + name=f"{project_name}-subnet", + zone=zone, + network_id=network.id, + folder_id=folder_id, + v4_cidr_blocks=["10.0.0.0/24"], +) + + +security_group = yandex.VpcSecurityGroup( + f"{project_name}-sg", + name=f"{project_name}-sg", + network_id=network.id, + folder_id=folder_id, + ingresses=[ + yandex.VpcSecurityGroupIngressArgs( + protocol="TCP", + description="SSH access", + port=22, + v4_cidr_blocks=[allowed_ssh_cidr], + ), + yandex.VpcSecurityGroupIngressArgs( + protocol="TCP", + description="HTTP", + port=80, + v4_cidr_blocks=["0.0.0.0/0"], + ), + yandex.VpcSecurityGroupIngressArgs( + protocol="TCP", + description="App port 5000", + port=5000, + v4_cidr_blocks=["0.0.0.0/0"], + ), + ], + egresses=[ + yandex.VpcSecurityGroupEgressArgs( + protocol="ANY", + description="Allow all outbound traffic", + v4_cidr_blocks=["0.0.0.0/0"], + ), + ], +) + + +vm = yandex.ComputeInstance( + f"{project_name}-vm", + name=f"{project_name}-vm", + platform_id="standard-v2", + zone=zone, + folder_id=folder_id, + 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, + security_group_ids=[security_group.id], + nat=True, # Allocate public IP + ), + ], + metadata=ssh_public_key.apply( + lambda key: { + "ssh-keys": f"{ssh_user}:{key}", + } + ), + labels={ + "project": project_name, + "env": "lab", + "managed": "pulumi", + }, +) + +public_ip = vm.network_interfaces[0].nat_ip_address + +pulumi.export("vm_name", vm.name) +pulumi.export("vm_id", vm.id) +pulumi.export("public_ip", public_ip) +pulumi.export( + "ssh_connection_command", + public_ip.apply(lambda ip: f"ssh {ssh_user}@{ip}"), +) +pulumi.export("network_id", network.id) +pulumi.export("security_group_id", security_group.id) diff --git a/pulumi/requirements.txt b/pulumi/requirements.txt new file mode 100644 index 0000000000..ad106a5476 --- /dev/null +++ b/pulumi/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=3.0.0,<4.0.0 +pulumi-yandex>=0.13.0 diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000000..b0af418e1d --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,13 @@ +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl +terraform.tfvars +*.tfvars +crash.log +crash.*.log + +*.pem +*.key +service-account-key.json +credentials.json diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..75fc742959 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,99 @@ +terraform { + required_providers { + yandex = { + source = "yandex-cloud/yandex" + } + } + required_version = ">= 1.9" +} + +provider "yandex" { + token = var.yc_token + cloud_id = var.yc_cloud_id + folder_id = var.yc_folder_id + zone = var.yc_zone +} + +# Network — use existing default network (free tier has quota of 1 network) + +data "yandex_vpc_network" "default" { + name = "default" +} + +resource "yandex_vpc_subnet" "main" { + name = "${var.project_name}-subnet" + zone = var.yc_zone + network_id = data.yandex_vpc_network.default.id + v4_cidr_blocks = ["10.0.0.0/24"] +} + +# Security Group + +resource "yandex_vpc_security_group" "main" { + name = "${var.project_name}-sg" + network_id = data.yandex_vpc_network.default.id + + ingress { + protocol = "TCP" + description = "SSH access" + port = 22 + v4_cidr_blocks = [var.allowed_ssh_cidr] + } + + ingress { + protocol = "TCP" + description = "HTTP" + port = 80 + v4_cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + protocol = "TCP" + description = "App port 5000" + port = 5000 + v4_cidr_blocks = ["0.0.0.0/0"] + } + + egress { + protocol = "ANY" + v4_cidr_blocks = ["0.0.0.0/0"] + } +} + +# Compute Instance + +resource "yandex_compute_instance" "main" { + name = "${var.project_name}-vm" + platform_id = "standard-v2" + zone = var.yc_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.main.id + security_group_ids = [yandex_vpc_security_group.main.id] + nat = true + } + + metadata = { + ssh-keys = "${var.ssh_user}:${file(var.ssh_public_key_path)}" + } + + labels = { + project = var.project_name + env = "lab" + managed = "terraform" + } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000000..7b4e9ad0a3 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,23 @@ +output "vm_public_ip" { + value = yandex_compute_instance.main.network_interface[0].nat_ip_address +} + +output "vm_name" { + value = yandex_compute_instance.main.name +} + +output "vm_id" { + value = yandex_compute_instance.main.id +} + +output "ssh_connection_command" { + value = "ssh ${var.ssh_user}@${yandex_compute_instance.main.network_interface[0].nat_ip_address}" +} + +output "network_id" { + value = data.yandex_vpc_network.default.id +} + +output "security_group_id" { + value = yandex_vpc_security_group.main.id +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..786aada883 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,42 @@ +variable "yc_token" { + type = string + sensitive = true +} + +variable "yc_cloud_id" { + type = string +} + +variable "yc_folder_id" { + type = string +} + +variable "yc_zone" { + type = string + default = "ru-central1-a" +} + +variable "project_name" { + type = string + default = "devops-lab04" +} + +variable "image_id" { + type = string + default = "fd83ica41cade1mj35sr" # Ubuntu 24.04 LTS v20251222 +} + +variable "ssh_user" { + type = string + default = "ubuntu" +} + +variable "ssh_public_key_path" { + type = string + default = "~/.ssh/id_rsa.pub" +} + +variable "allowed_ssh_cidr" { + type = string + default = "0.0.0.0/0" +}