diff --git a/.github/workflows/ansible-deploy.yml b/.github/workflows/ansible-deploy.yml
new file mode 100644
index 0000000000..e9abe948ed
--- /dev/null
+++ b/.github/workflows/ansible-deploy.yml
@@ -0,0 +1,89 @@
+name: Ansible Deployment
+
+on:
+ push:
+ branches: [main, master]
+ paths:
+ - 'ansible/**'
+ - '!ansible/docs/**'
+ - '.github/workflows/ansible-deploy.yml'
+ pull_request:
+ branches: [main, master]
+ paths:
+ - 'ansible/**'
+ - '!ansible/docs/**'
+
+jobs:
+ lint:
+ name: Ansible Lint
+ runs-on: ubuntu-latest
+ steps:
+ - 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
+ env:
+ ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
+ run: |
+ cd ansible
+ echo "$ANSIBLE_VAULT_PASSWORD" > .vault_pass
+ ansible-lint playbooks/*.yml
+ rm -f .vault_pass
+
+ deploy:
+ name: Deploy Application
+ needs: lint
+ if: github.event_name == 'push'
+ 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 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 2>/dev/null || true
+
+ - name: Create vault password file
+ run: echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > /tmp/vault_pass
+
+ - name: Update inventory with CI host
+ run: |
+ cd ansible
+ echo "[webservers]" > inventory/hosts.ini
+ echo "myvm ansible_host=${{ secrets.VM_HOST }} ansible_port=${{ secrets.VM_PORT }} ansible_user=${{ secrets.VM_USER }} ansible_ssh_private_key_file=~/.ssh/id_rsa ansible_ssh_common_args='-o StrictHostKeyChecking=no'" >> inventory/hosts.ini
+
+ - name: Run Ansible playbook
+ run: |
+ cd ansible
+ ansible-playbook playbooks/deploy.yml \
+ --vault-password-file /tmp/vault_pass
+
+ - name: Verify Deployment
+ run: |
+ sleep 10
+ curl -f http://${{ secrets.VM_HOST }}:5000/health || exit 1
+
+ - name: Cleanup secrets
+ if: always()
+ run: |
+ rm -f /tmp/vault_pass ~/.ssh/id_rsa
diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml
new file mode 100644
index 0000000000..f62168ba5f
--- /dev/null
+++ b/.github/workflows/python-ci.yml
@@ -0,0 +1,109 @@
+name: Python CI/CD
+
+on:
+ push:
+ branches: ["main", "master", "lab03"]
+ paths:
+ - "app_python/**"
+ - ".github/workflows/python-ci.yml"
+ pull_request:
+ branches: ["main", "master"]
+ paths:
+ - "app_python/**"
+ - ".github/workflows/python-ci.yml"
+ workflow_dispatch:
+
+concurrency:
+ group: python-ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+env:
+ IMAGE_NAME: devops-info-service
+ PYTHON_VERSION: "3.11"
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+ cache: "pip"
+ cache-dependency-path: |
+ app_python/requirements.txt
+ app_python/requirements-dev.txt
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r app_python/requirements.txt -r app_python/requirements-dev.txt
+
+ - name: Lint (ruff)
+ run: ruff check app_python
+
+ - name: Run tests
+ env:
+ PYTHONPATH: app_python
+ run: |
+ pytest app_python/tests \
+ --cov=app_python \
+ --cov-report=term-missing \
+ --cov-report=xml \
+ --cov-fail-under=70
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v5
+ with:
+ files: ./coverage.xml
+ token: ${{ secrets.CODECOV_TOKEN }}
+ fail_ci_if_error: false
+ verbose: true
+
+ - name: Snyk scan
+ if: ${{ env.SNYK_TOKEN != '' }}
+ uses: snyk/actions/python@v1
+ env:
+ SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
+ with:
+ args: --severity-threshold=high --file=app_python/requirements.txt
+
+ docker:
+ needs: test
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ if: >
+ github.event_name == 'workflow_dispatch' ||
+ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' || github.ref == 'refs/heads/lab03'))
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set version (CalVer)
+ run: echo "VERSION=$(date -u +'%Y.%m.%d')" >> $GITHUB_ENV
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build and push image
+ uses: docker/build-push-action@v6
+ with:
+ context: app_python
+ file: app_python/Dockerfile
+ push: true
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ tags: |
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest
diff --git a/.github/workflows/terraform-ci.yml b/.github/workflows/terraform-ci.yml
new file mode 100644
index 0000000000..3cdc884bbb
--- /dev/null
+++ b/.github/workflows/terraform-ci.yml
@@ -0,0 +1,50 @@
+name: Terraform CI
+
+on:
+ pull_request:
+ paths:
+ - 'terraform/**'
+ - '.github/workflows/terraform-ci.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'terraform/**'
+ - '.github/workflows/terraform-ci.yml'
+
+jobs:
+ validate:
+ name: Validate Terraform
+ runs-on: ubuntu-latest
+
+ defaults:
+ run:
+ working-directory: terraform/
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Terraform
+ uses: hashicorp/setup-terraform@v3
+ with:
+ terraform_version: "1.9.0"
+
+ - name: Terraform Format Check
+ run: terraform fmt -check -recursive
+
+ - name: Terraform Init
+ run: terraform init -backend=false
+
+ - name: Terraform Validate
+ run: terraform validate
+
+ - name: Setup TFLint
+ uses: terraform-linters/setup-tflint@v4
+ with:
+ tflint_version: latest
+
+ - name: Init TFLint
+ run: tflint --init
+
+ - name: Run TFLint
+ run: tflint --format compact
diff --git a/.gitignore b/.gitignore
index 30d74d2584..10d5f97e5d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,28 @@
-test
\ No newline at end of file
+test
+/.venv
+.coverage*
+coverage.xml
+
+# Terraform
+*.tfstate
+*.tfstate.*
+.terraform/
+.terraform.lock.hcl
+terraform.tfvars
+*.tfvars
+crash.log
+*.pem
+*.key
+*.box
+*.ova
+
+# Pulumi
+.pulumi/
+__pycache__/
+venv/
+.pulumi-cache/
+
+# Ansible
+*.retry
+.vault_pass
+ansible/inventory/*.pyc
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000000..df748dcfbf
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,4 @@
+{
+ "python-envs.defaultEnvManager": "ms-python.python:system",
+ "python-envs.pythonProjects": []
+}
\ No newline at end of file
diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg
new file mode 100644
index 0000000000..452b4f5ff6
--- /dev/null
+++ b/ansible/ansible.cfg
@@ -0,0 +1,12 @@
+[defaults]
+inventory = inventory/hosts.ini
+roles_path = roles
+host_key_checking = False
+remote_user = vagrant
+retry_files_enabled = False
+vault_password_file = .vault_pass
+
+[privilege_escalation]
+become = True
+become_method = sudo
+become_user = root
diff --git a/ansible/docs/LAB05.md b/ansible/docs/LAB05.md
new file mode 100644
index 0000000000..8e4c9f3bae
--- /dev/null
+++ b/ansible/docs/LAB05.md
@@ -0,0 +1,257 @@
+# Lab 05 — Ansible Fundamentals
+
+## 1. Architecture Overview
+
+**Ansible version:** 2.16+
+**Target VM:** Ubuntu 22.04 LTS (local VirtualBox VM from Lab 04, created via Pulumi)
+**Connection:** SSH via NAT port forwarding (`127.0.0.1:2223`, user `vagrant`)
+
+### Role Structure
+
+```
+ansible/
+├── ansible.cfg # Ansible configuration
+├── inventory/
+│ └── hosts.ini # Static inventory (VM connection details)
+├── roles/
+│ ├── common/ # System setup: apt update, essential packages
+│ │ ├── tasks/main.yml
+│ │ └── defaults/main.yml
+│ ├── docker/ # Docker CE installation and configuration
+│ │ ├── tasks/main.yml
+│ │ ├── handlers/main.yml
+│ │ └── defaults/main.yml
+│ └── app_deploy/ # Application deployment via Docker
+│ ├── tasks/main.yml
+│ ├── handlers/main.yml
+│ └── defaults/main.yml
+├── playbooks/
+│ ├── site.yml # Master playbook (provision + deploy)
+│ ├── provision.yml # System provisioning (common + docker)
+│ └── deploy.yml # App deployment
+├── group_vars/
+│ └── all.yml # Encrypted variables (Ansible Vault)
+└── docs/
+ └── LAB05.md # This documentation
+```
+
+**Why roles instead of monolithic playbooks?**
+Roles provide reusability, modularity, and clear separation of concerns. Each role handles one specific responsibility (system setup, Docker, app deploy) and can be tested or reused independently.
+
+---
+
+## 2. Roles Documentation
+
+### Role: `common`
+
+- **Purpose:** Update apt cache and install essential system packages.
+- **Variables:** `common_packages` — list of packages to install (python3-pip, curl, git, vim, htop, etc.).
+- **Handlers:** None.
+- **Dependencies:** None.
+
+### Role: `docker`
+
+- **Purpose:** Install Docker CE from the official repository, enable the service, and add user to the docker group.
+- **Variables:** `docker_user` — user to add to the docker group (default: `vagrant`).
+- **Handlers:** `restart docker` — triggered when Docker packages are installed or configuration changes.
+- **Dependencies:** None (but should run after `common`).
+
+### Role: `app_deploy`
+
+- **Purpose:** Pull and run the Python application container from Docker Hub.
+- **Variables:** `app_port`, `app_restart_policy` (defaults), plus vaulted variables: `dockerhub_username`, `dockerhub_password`, `docker_image`, `docker_image_tag`, `app_container_name`.
+- **Handlers:** `restart app container` — restarts the application container if needed.
+- **Dependencies:** Requires Docker to be installed (role `docker`).
+
+---
+
+## 3. Idempotency Demonstration
+
+### First Run (`ansible-playbook playbooks/provision.yml`)
+
+```
+PLAY [Provision web servers] **************************************************
+
+TASK [Gathering Facts] ********************************************************
+ok: [myvm]
+
+TASK [common : Update apt cache] **********************************************
+changed: [myvm]
+
+TASK [common : Install common packages] ***************************************
+changed: [myvm]
+
+TASK [docker : Add Docker GPG key] ********************************************
+changed: [myvm]
+
+TASK [docker : Add Docker repository] *****************************************
+changed: [myvm]
+
+TASK [docker : Install Docker CE packages] ************************************
+changed: [myvm]
+
+TASK [docker : Ensure Docker service is started and enabled] ******************
+ok: [myvm]
+
+TASK [docker : Add vagrant user to docker group] ******************************
+ok: [myvm]
+
+TASK [docker : Install python3-docker] ****************************************
+ok: [myvm]
+
+RUNNING HANDLER [docker : restart docker] *************************************
+changed: [myvm]
+
+PLAY RECAP ********************************************************************
+myvm : ok=12 changed=4 unreachable=0 failed=0 skipped=0
+```
+
+Many tasks show **"changed"** (yellow) — packages installed, Docker repo added, service started.
+
+### Second Run (`ansible-playbook playbooks/provision.yml`)
+
+```
+PLAY [Provision web servers] **************************************************
+
+TASK [Gathering Facts] ********************************************************
+ok: [myvm]
+
+TASK [common : Update apt cache] **********************************************
+ok: [myvm]
+
+TASK [common : Install common packages] ***************************************
+ok: [myvm]
+
+TASK [docker : Add Docker GPG key] ********************************************
+ok: [myvm]
+
+TASK [docker : Add Docker repository] *****************************************
+ok: [myvm]
+
+TASK [docker : Install Docker CE packages] ************************************
+ok: [myvm]
+
+TASK [docker : Ensure Docker service is started and enabled] ******************
+ok: [myvm]
+
+TASK [docker : Add vagrant user to docker group] ******************************
+ok: [myvm]
+
+TASK [docker : Install python3-docker] ****************************************
+ok: [myvm]
+
+PLAY RECAP ********************************************************************
+myvm : ok=11 changed=0 unreachable=0 failed=0 skipped=0
+```
+
+All tasks show **"ok"** (green), zero "changed". This proves idempotency.
+
+### Analysis
+
+- **First run:** apt cache updated, packages installed, Docker GPG key added, Docker repo configured, Docker service started, user added to docker group — all new changes.
+- **Second run:** All desired states already achieved. Ansible detects no drift, makes no changes.
+- **What makes roles idempotent:** Using declarative modules like `apt: state=present`, `service: state=started`, `user: groups=docker append=yes` — they check current state before acting.
+
+---
+
+## 4. Ansible Vault Usage
+
+Credentials are stored in `group_vars/all.yml`, encrypted with Ansible Vault.
+
+**How credentials are stored:** The file contains DockerHub username and access token, encrypted at rest.
+
+**Vault password management:** Password is entered interactively via `--ask-vault-pass`, or stored in `.vault_pass` (excluded from git via `.gitignore`).
+
+**Encrypted file example:**
+```
+$ANSIBLE_VAULT;1.1;AES256
+31396664316237616632386465333739343530653266616435656233653337656365656164346233
+3632633136386562653139376639393739313962626461620a633563366631343438633739653732
+...
+```
+
+**Why Ansible Vault is important:** It prevents plaintext secrets from being committed to version control. Credentials remain encrypted and are only decrypted in memory during playbook execution.
+
+---
+
+## 5. Deployment Verification
+
+### Deploy Run (`ansible-playbook playbooks/deploy.yml --vault-password-file .vault_pass`)
+
+```
+PLAY [Deploy application] *****************************************************
+
+TASK [Gathering Facts] ********************************************************
+ok: [myvm]
+
+TASK [app_deploy : Log in to Docker Hub] **************************************
+changed: [myvm]
+
+TASK [app_deploy : Pull Docker image] *****************************************
+changed: [myvm]
+
+TASK [app_deploy : Remove old container (if exists)] **************************
+ok: [myvm]
+
+TASK [app_deploy : Run application container] *********************************
+changed: [myvm]
+
+TASK [app_deploy : Wait for application to be ready] **************************
+ok: [myvm]
+
+TASK [app_deploy : Verify health endpoint] ************************************
+ok: [myvm]
+
+TASK [app_deploy : Show health check result] **********************************
+ok: [myvm] => {
+ "health_result.json": {
+ "status": "healthy",
+ "timestamp": "2026-02-20T20:14:30.313Z",
+ "uptime_seconds": 3
+ }
+}
+
+PLAY RECAP ********************************************************************
+myvm : ok=8 changed=3 unreachable=0 failed=0 skipped=0
+```
+
+### Container Status (`docker ps`)
+
+```
+CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
+4fdc191f5d76 vladimirzhidkov/devops-info-service:latest "python app.py" 4 minutes ago Up 4 minutes 0.0.0.0:5000->5000/tcp devops-info-service
+```
+
+### Health Check
+
+```
+$ curl http://localhost:5000/health
+{"status":"healthy","timestamp":"2026-02-20T20:18:43.889Z","uptime_seconds":257}
+```
+
+---
+
+## 6. Key Decisions
+
+- **Why use roles instead of plain playbooks?**
+ Roles separate concerns, making each component independently testable and reusable across projects.
+
+- **How do roles improve reusability?**
+ A role like `docker` can be dropped into any project that needs Docker. Variables in `defaults/` allow customization without modifying role code.
+
+- **What makes a task idempotent?**
+ Using declarative state-based modules (`state: present`, `state: started`) instead of imperative commands. Ansible checks current state before making changes.
+
+- **How do handlers improve efficiency?**
+ Handlers only run when notified by a changed task, and only once at the end of the play. This prevents unnecessary service restarts.
+
+- **Why is Ansible Vault necessary?**
+ Secrets (passwords, tokens) must not be stored in plaintext in version control. Vault encrypts them, allowing safe commits while keeping secrets accessible during execution.
+
+---
+
+## 7. Challenges
+
+- Ansible does not run natively on Windows — used WSL2 as the control node.
+- VM uses password-based SSH — required `sshpass` package and `ansible_password` in inventory.
+- NAT port forwarding means using `127.0.0.1:2223` instead of a direct IP.
diff --git a/ansible/docs/LAB06.md b/ansible/docs/LAB06.md
new file mode 100644
index 0000000000..7a1a078a81
--- /dev/null
+++ b/ansible/docs/LAB06.md
@@ -0,0 +1,260 @@
+# Lab 6: Advanced Ansible & CI/CD
+
+**Name:** Vladimir Zhidkov
+**Date:** 2026-02-20
+**Lab Points:** 10
+
+---
+
+## Task 1: Blocks & Tags (2 pts)
+
+### Block Usage
+
+All roles refactored with blocks for logical grouping and error handling.
+
+#### `common` role
+
+- **Package block** (`tags: packages`): Groups apt cache update and package installation. Rescue block runs `apt-get update --fix-missing` on failure. Always block logs completion to `/tmp/ansible_common_done.log`.
+
+#### `docker` role
+
+- **Install block** (`tags: docker_install`): Groups all Docker installation tasks (prerequisites, GPG key, repo, packages). Rescue block waits 10 seconds and retries on failure.
+- **Config block** (`tags: docker_config`): Groups service start, user group, python3-docker. Always block ensures Docker service is enabled.
+
+#### `web_app` role
+
+- **Deploy block** (`tags: app_deploy, compose`): Groups Docker login, compose template, pull, deploy, health check. Rescue block logs failure details and fails the play.
+
+### Tag Strategy
+
+| Tag | Scope | Description |
+|-----|-------|-------------|
+| `packages` | common | Package installation |
+| `common` | common role | Entire common role |
+| `docker` | docker role | Entire docker role |
+| `docker_install` | docker | Docker installation tasks |
+| `docker_config` | docker | Docker configuration tasks |
+| `app_deploy` | web_app | Deployment tasks |
+| `compose` | web_app | Docker Compose tasks |
+| `web_app_wipe` | web_app | Wipe/cleanup tasks |
+
+### Tag Execution Examples
+
+```bash
+# Run only docker installation
+ansible-playbook playbooks/provision.yml --tags "docker_install"
+
+# Skip common role
+ansible-playbook playbooks/provision.yml --skip-tags "common"
+
+# List all tags
+ansible-playbook playbooks/provision.yml --list-tags
+```
+
+### Evidence
+
+
+
+### Research Answers
+
+- **What happens if rescue block also fails?** The play fails entirely. Ansible does not have a "rescue of rescue" — the always block still runs though.
+- **Can you have nested blocks?** Yes, blocks can be nested within other blocks for more granular error handling.
+- **How do tags inherit to tasks within blocks?** Tags applied at block level are inherited by all tasks inside the block. Tasks can also have their own additional tags.
+
+---
+
+## Task 2: Docker Compose (3 pts)
+
+### Migration from `docker run` to Docker Compose
+
+Renamed `app_deploy` → `web_app` role. Replaced `community.docker.docker_container` module with Docker Compose template + `docker compose` CLI.
+
+### Template Structure
+
+**`roles/web_app/templates/docker-compose.yml.j2`:**
+```yaml
+version: '3.8'
+
+services:
+ {{ app_name }}:
+ image: {{ docker_image }}:{{ docker_image_tag }}
+ container_name: {{ app_name }}
+ ports:
+ - "{{ app_port }}:{{ app_internal_port }}"
+ restart: {{ app_restart_policy }}
+ environment:
+ APP_NAME: "{{ app_name }}"
+```
+
+### Role Dependencies
+
+**`roles/web_app/meta/main.yml`** declares `docker` as a dependency, so running only `deploy.yml` automatically ensures Docker is installed first.
+
+### Before/After Comparison
+
+| Aspect | Before (Lab 5) | After (Lab 6) |
+|--------|----------------|----------------|
+| Deployment | `docker run` via community.docker | Docker Compose template |
+| Config | Ansible variables inline | `docker-compose.yml.j2` template |
+| Management | Individual docker commands | `docker compose up/down` |
+| Error handling | None | Block/rescue/always |
+| Tags | None | `app_deploy`, `compose` |
+| Wipe logic | None | `web_app_wipe` variable + tag |
+
+### Evidence
+
+
+
+---
+
+## Task 3: Wipe Logic (1 pt)
+
+### Implementation
+
+Wipe logic uses **double gating** — both a variable (`web_app_wipe: true`) AND a tag (`web_app_wipe`) must be active for wipe to execute.
+
+**`roles/web_app/tasks/wipe.yml`** performs:
+1. `docker compose down --remove-orphans`
+2. Remove docker-compose.yml
+3. Remove application directory
+4. Remove Docker image (optional)
+
+### Test Scenarios
+
+**Scenario 1: Normal deployment** — wipe does NOT run (tag not specified, variable false by default).
+
+**Scenario 2: Wipe only:**
+```bash
+ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe
+```
+Result: App removed, no redeployment.
+
+**Scenario 3: Clean reinstallation:**
+```bash
+ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true"
+```
+Result: Wipe runs first, then fresh deployment.
+
+**Scenario 4: Tag without variable:**
+```bash
+ansible-playbook playbooks/deploy.yml --tags web_app_wipe
+```
+Result: Wipe tasks are included but skipped (`when: web_app_wipe | bool` is false).
+
+### Research Answers
+
+1. **Why use both variable AND tag?** Double safety — variable prevents accidental execution even if tag is specified, and tag prevents wipe from running during normal deploys.
+2. **Difference from `never` tag?** The `never` tag requires `--tags never` to run, while this approach allows combining wipe with deployment (clean reinstall scenario).
+3. **Why wipe before deployment?** Enables clean reinstallation workflow: remove old → install new, all in one playbook run.
+4. **Clean reinstall vs rolling update?** Clean reinstall ensures no leftover state; rolling update is faster but may carry forward old configs.
+5. **Extending wipe to include volumes?** Add `docker volume prune -f` or target specific volumes in the wipe block.
+
+### Evidence
+
+
+
+---
+
+## Task 4: CI/CD (3 pts)
+
+### Workflow Architecture
+
+**`.github/workflows/ansible-deploy.yml`:**
+
+```
+Push to ansible/** → Lint Job → Deploy Job → Verify
+```
+
+**Lint job:** Installs `ansible-lint`, checks all playbooks for best practices.
+
+**Deploy job:** Configures SSH, creates vault password from GitHub Secret, runs `ansible-playbook deploy.yml`, verifies health endpoint.
+
+### GitHub Secrets Required
+
+| Secret | Purpose |
+|--------|---------|
+| `ANSIBLE_VAULT_PASSWORD` | Decrypt vault-encrypted variables |
+| `SSH_PRIVATE_KEY` | SSH access to target VM |
+| `VM_HOST` | Target VM IP address |
+| `VM_PORT` | SSH port (e.g., 2223) |
+| `VM_USER` | SSH username |
+
+### Path Filters
+
+Workflow triggers only on changes to `ansible/**` (excluding `ansible/docs/**`), preventing unnecessary runs on documentation changes.
+
+### Security
+
+- Vault password stored in GitHub Secrets (never in code)
+- SSH key cleaned up in `always` block
+- Temporary files removed after use
+
+### Research Answers
+
+1. **Security of SSH keys in GitHub Secrets:** Encrypted at rest, only available to workflows in the repo. Risk: anyone with push access can exfiltrate them via workflow. Mitigate with branch protection and required reviews.
+2. **Staging → production pipeline:** Add environments in GitHub Actions with separate secrets, require manual approval for production.
+3. **Rollbacks:** Tag Docker images with commit SHA, keep previous image; add rollback playbook that deploys previous tag.
+4. **Self-hosted vs GitHub-hosted:** Self-hosted has direct network access (no SSH needed), secrets don't leave infrastructure, but requires maintenance.
+
+---
+
+## Task 5: Documentation
+
+This file serves as the complete Lab 6 documentation.
+
+### File Structure After Lab 6
+
+```
+ansible/
+├── ansible.cfg
+├── inventory/
+│ └── hosts.ini
+├── roles/
+│ ├── common/
+│ │ ├── tasks/main.yml # Refactored with blocks & tags
+│ │ └── defaults/main.yml
+│ ├── docker/
+│ │ ├── tasks/main.yml # Refactored with blocks & tags
+│ │ ├── handlers/main.yml
+│ │ └── defaults/main.yml
+│ └── web_app/ # Renamed from app_deploy
+│ ├── tasks/
+│ │ ├── main.yml # Docker Compose deployment
+│ │ └── wipe.yml # Wipe logic
+│ ├── handlers/main.yml
+│ ├── defaults/main.yml
+│ ├── templates/
+│ │ └── docker-compose.yml.j2
+│ └── meta/main.yml # Role dependencies
+├── playbooks/
+│ ├── site.yml
+│ ├── provision.yml # Tags: common, docker
+│ └── deploy.yml # Tags: app_deploy, web_app_wipe
+├── group_vars/
+│ └── all.yml # Ansible Vault encrypted
+└── docs/
+ ├── LAB05.md
+ └── LAB06.md
+.github/
+└── workflows/
+ └── ansible-deploy.yml # CI/CD pipeline
+```
+
+---
+
+## Challenges & Solutions
+
+- **Rename `app_deploy` → `web_app`**: Required updating all playbook role references.
+- **Docker Compose on Ubuntu 22.04**: Used `docker-compose-plugin` (v2) instead of standalone `docker-compose` (v1). Commands use `docker compose` (space, not hyphen).
+- **Wipe logic safety**: Implemented double gating (variable + tag) to prevent accidental data loss.
+- **CI/CD SSH access**: GitHub-hosted runners need SSH key + host scanning; self-hosted runners have direct access.
+
+---
+
+## Summary
+
+- Refactored all 3 roles with blocks, rescue/always, and comprehensive tag strategy
+- Migrated from `docker run` to Docker Compose with Jinja2 templating
+- Implemented role dependencies (`web_app` depends on `docker`)
+- Created double-gated wipe logic for safe cleanup
+- Built CI/CD pipeline with ansible-lint + automated deployment
diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml
new file mode 100644
index 0000000000..5210850403
--- /dev/null
+++ b/ansible/group_vars/all.yml
@@ -0,0 +1,22 @@
+$ANSIBLE_VAULT;1.1;AES256
+31396664316237616632386465333739343530653266616435656233653337656365656164346233
+3632633136386562653139376639393739313962626461620a633563366631343438633739653732
+35643934646339356264646364613930363735383333336436616264613335653532613337323361
+3332336533656433330a656164613332396339643736636237623938646665636137323061383734
+33373065336366376531363563383562353732656238306430633338646531626330646262383138
+62663439646532633435623865363835343865386637373464323336343234376639383435663732
+30353435386533646264383862626332663935383666333964656461336132623932326139323935
+61373534616166633862643835373737326165333734323536323261343532646434353333336666
+63373566353738643065393065643466333436633536643164646161396564613361333933343637
+64383039626364633437353436383361303062363166613537333264646261336133623361666463
+63656633613237373230613537383265373837333233316637623735333536323133353236653534
+36343131333866303234353436396239396265306262653239396436306662333861303637363362
+34643962356662646337306562613164636263613266376533333934383766393131636531636264
+30656164626166663061626131323139336333613964346361353765306137663032643631646361
+31623364373835326664633534363333353339323031366334343363333931633934363739316438
+62376339366238653231303137353764386131316632653661656236663363626362663961366365
+35393966386264326631353630363138386265386332346233363834356530316235353036643363
+34653265396439373463386533303165363534306539306535343764623630323533353437373535
+39643935336430666337323932646539366338376362356138616631346631303566373234653262
+34353839356564393732306339616238393333393330306432383364316665613439616633646233
+3731
diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini
new file mode 100644
index 0000000000..f0362364d6
--- /dev/null
+++ b/ansible/inventory/hosts.ini
@@ -0,0 +1,2 @@
+[webservers]
+myvm ansible_host=127.0.0.1 ansible_port=2223 ansible_user=vagrant ansible_password=vagrant ansible_ssh_common_args='-o StrictHostKeyChecking=no'
diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml
new file mode 100644
index 0000000000..1288a42727
--- /dev/null
+++ b/ansible/playbooks/deploy.yml
@@ -0,0 +1,13 @@
+---
+- name: Deploy application
+ hosts: webservers
+ become: true
+ vars_files:
+ - "../group_vars/all.yml"
+
+ roles:
+ - role: web_app
+ tags:
+ - app_deploy
+ - compose
+ - web_app_wipe
diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml
new file mode 100644
index 0000000000..f7250a373a
--- /dev/null
+++ b/ansible/playbooks/provision.yml
@@ -0,0 +1,17 @@
+---
+- name: Provision web servers
+ hosts: webservers
+ become: true
+ vars_files:
+ - "../group_vars/all.yml"
+
+ roles:
+ - role: common
+ tags:
+ - common
+ - packages
+ - role: docker
+ tags:
+ - docker
+ - docker_install
+ - docker_config
diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml
new file mode 100644
index 0000000000..cc3d0bf1a2
--- /dev/null
+++ b/ansible/playbooks/site.yml
@@ -0,0 +1,5 @@
+---
+- name: Provision infrastructure
+ ansible.builtin.import_playbook: provision.yml
+- name: Deploy application
+ ansible.builtin.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..89be9574dc
--- /dev/null
+++ b/ansible/roles/common/defaults/main.yml
@@ -0,0 +1,12 @@
+---
+common_packages:
+ - python3-pip
+ - curl
+ - git
+ - vim
+ - htop
+ - ca-certificates
+ - gnupg
+ - lsb-release
+ - apt-transport-https
+ - software-properties-common
diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml
new file mode 100644
index 0000000000..a70886ad7c
--- /dev/null
+++ b/ansible/roles/common/tasks/main.yml
@@ -0,0 +1,34 @@
+---
+# Package installation block with error handling
+- name: Install system packages
+ become: true
+ tags:
+ - packages
+ block:
+ - name: Update apt cache
+ ansible.builtin.apt:
+ update_cache: true
+ cache_valid_time: 3600
+
+ - name: Install common packages
+ ansible.builtin.apt:
+ name: "{{ common_packages }}"
+ state: present
+
+ rescue:
+ - name: Fix apt cache on failure
+ ansible.builtin.apt:
+ update_cache: true
+ changed_when: true
+
+ - name: Retry package installation
+ ansible.builtin.apt:
+ name: "{{ common_packages }}"
+ state: present
+
+ always:
+ - name: Log package installation completion
+ ansible.builtin.copy:
+ content: "Common packages provisioned at {{ ansible_date_time.iso8601 }}\n"
+ dest: /tmp/ansible_common_done.log
+ mode: '0644'
diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml
new file mode 100644
index 0000000000..61a71be1e3
--- /dev/null
+++ b/ansible/roles/docker/defaults/main.yml
@@ -0,0 +1,2 @@
+---
+docker_user: vagrant
diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml
new file mode 100644
index 0000000000..55637bda17
--- /dev/null
+++ b/ansible/roles/docker/handlers/main.yml
@@ -0,0 +1,5 @@
+---
+- name: Restart docker
+ ansible.builtin.service:
+ name: docker
+ state: restarted
diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml
new file mode 100644
index 0000000000..f8d7af0698
--- /dev/null
+++ b/ansible/roles/docker/tasks/main.yml
@@ -0,0 +1,92 @@
+---
+# Docker installation block with error handling
+- name: Install Docker Engine
+ become: true
+ tags:
+ - docker_install
+ block:
+ - name: Install prerequisites for Docker repository
+ ansible.builtin.apt:
+ name:
+ - ca-certificates
+ - curl
+ - gnupg
+ state: present
+
+ - name: Create keyrings directory
+ ansible.builtin.file:
+ path: /etc/apt/keyrings
+ state: directory
+ mode: '0755'
+
+ - name: Add Docker GPG key
+ ansible.builtin.apt_key:
+ url: https://download.docker.com/linux/ubuntu/gpg
+ state: present
+
+ - name: Add Docker repository
+ ansible.builtin.apt_repository:
+ repo: "deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
+ state: present
+ filename: docker
+
+ - name: Install Docker packages
+ ansible.builtin.apt:
+ name:
+ - docker-ce
+ - docker-ce-cli
+ - containerd.io
+ - docker-buildx-plugin
+ - docker-compose-plugin
+ state: present
+ update_cache: true
+ notify: Restart docker
+
+ rescue:
+ - name: Wait before retrying Docker installation
+ ansible.builtin.pause:
+ seconds: 10
+
+ - name: Retry apt update after failure
+ ansible.builtin.apt:
+ update_cache: true
+
+ - name: Retry Docker packages installation
+ ansible.builtin.apt:
+ name:
+ - docker-ce
+ - docker-ce-cli
+ - containerd.io
+ - docker-buildx-plugin
+ - docker-compose-plugin
+ state: present
+ notify: Restart docker
+
+# Docker configuration block
+- name: Configure Docker
+ become: true
+ tags:
+ - docker_config
+ block:
+ - name: Ensure Docker service is started and enabled
+ ansible.builtin.service:
+ name: docker
+ state: started
+ enabled: true
+
+ - name: Add user to docker group
+ ansible.builtin.user:
+ name: "{{ docker_user }}"
+ groups: docker
+ append: true
+
+ - name: Install python3-docker for Ansible docker modules
+ ansible.builtin.apt:
+ name: python3-docker
+ state: present
+
+ always:
+ - name: Ensure Docker service is enabled
+ ansible.builtin.service:
+ name: docker
+ enabled: true
diff --git a/ansible/roles/web_app/defaults/main.yml b/ansible/roles/web_app/defaults/main.yml
new file mode 100644
index 0000000000..fa6b790b08
--- /dev/null
+++ b/ansible/roles/web_app/defaults/main.yml
@@ -0,0 +1,15 @@
+---
+# Application Configuration
+web_app_name: devops-info-service
+web_app_port: 5000
+web_app_internal_port: 5000
+web_app_restart_policy: unless-stopped
+
+# Docker Compose
+web_app_compose_project_dir: "/opt/{{ web_app_name }}"
+
+# Wipe Logic Control
+# Set to true to remove application completely
+# Wipe only: ansible-playbook deploy.yml -e "web_app_wipe=true" --tags web_app_wipe
+# Clean install: ansible-playbook deploy.yml -e "web_app_wipe=true"
+web_app_wipe: false
diff --git a/ansible/roles/web_app/handlers/main.yml b/ansible/roles/web_app/handlers/main.yml
new file mode 100644
index 0000000000..228f9ad6fb
--- /dev/null
+++ b/ansible/roles/web_app/handlers/main.yml
@@ -0,0 +1,6 @@
+---
+- name: Restart app compose
+ ansible.builtin.command:
+ cmd: docker compose up -d --force-recreate
+ chdir: "{{ web_app_compose_project_dir }}"
+ changed_when: true
diff --git a/ansible/roles/web_app/meta/main.yml b/ansible/roles/web_app/meta/main.yml
new file mode 100644
index 0000000000..8afff1479c
--- /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..fb225bc12e
--- /dev/null
+++ b/ansible/roles/web_app/tasks/main.yml
@@ -0,0 +1,74 @@
+---
+# Wipe logic (runs first when explicitly requested)
+- name: Include wipe tasks
+ ansible.builtin.include_tasks: wipe.yml
+ tags:
+ - web_app_wipe
+
+# Deploy application with Docker Compose
+- name: Deploy application with Docker Compose
+ tags:
+ - app_deploy
+ - compose
+ block:
+ - name: Log in to Docker Hub
+ ansible.builtin.shell:
+ cmd: set -o pipefail && echo "$DHPASS" | docker login --username "$DHUSER" --password-stdin
+ executable: /bin/bash
+ environment:
+ DHUSER: "{{ dockerhub_username }}"
+ DHPASS: "{{ dockerhub_password }}"
+ no_log: true
+ changed_when: true
+
+ - name: Create application directory
+ ansible.builtin.file:
+ path: "{{ web_app_compose_project_dir }}"
+ state: directory
+ mode: '0755'
+
+ - name: Template docker-compose file
+ ansible.builtin.template:
+ src: docker-compose.yml.j2
+ dest: "{{ web_app_compose_project_dir }}/docker-compose.yml"
+ mode: '0644'
+ notify: Restart app compose
+
+ - name: Pull Docker image
+ ansible.builtin.command:
+ cmd: docker compose pull
+ chdir: "{{ web_app_compose_project_dir }}"
+ register: web_app_pull_result
+ changed_when: "'Downloaded' in web_app_pull_result.stderr or 'Pull complete' in web_app_pull_result.stderr"
+
+ - name: Deploy with docker compose
+ ansible.builtin.command:
+ cmd: docker compose up -d
+ chdir: "{{ web_app_compose_project_dir }}"
+ register: web_app_compose_result
+ changed_when: "'Started' in web_app_compose_result.stderr or 'Creating' in web_app_compose_result.stderr"
+
+ - name: Wait for application to be ready
+ ansible.builtin.wait_for:
+ port: "{{ web_app_port }}"
+ delay: 3
+ timeout: 30
+
+ - name: Verify health endpoint
+ ansible.builtin.uri:
+ url: "http://localhost:{{ web_app_port }}/health"
+ status_code: 200
+ register: web_app_health_result
+
+ - name: Show health check result
+ ansible.builtin.debug:
+ var: web_app_health_result.json
+
+ rescue:
+ - name: Log deployment failure
+ ansible.builtin.debug:
+ msg: "Deployment of {{ web_app_name }} failed. Check logs with: docker compose -f {{ web_app_compose_project_dir }}/docker-compose.yml logs"
+
+ - name: Fail the play
+ ansible.builtin.fail:
+ msg: "Application deployment failed"
diff --git a/ansible/roles/web_app/tasks/wipe.yml b/ansible/roles/web_app/tasks/wipe.yml
new file mode 100644
index 0000000000..8f013750db
--- /dev/null
+++ b/ansible/roles/web_app/tasks/wipe.yml
@@ -0,0 +1,33 @@
+---
+# Wipe web application
+- name: Wipe web application
+ when: web_app_wipe | bool
+ tags:
+ - web_app_wipe
+ block:
+ - name: Stop and remove containers via docker compose
+ ansible.builtin.command:
+ cmd: docker compose down --remove-orphans
+ chdir: "{{ web_app_compose_project_dir }}"
+ failed_when: false
+ changed_when: true
+
+ - name: Remove docker-compose file
+ ansible.builtin.file:
+ path: "{{ web_app_compose_project_dir }}/docker-compose.yml"
+ state: absent
+
+ - name: Remove application directory
+ ansible.builtin.file:
+ path: "{{ web_app_compose_project_dir }}"
+ state: absent
+
+ - name: Remove Docker image
+ ansible.builtin.command:
+ cmd: "docker rmi {{ docker_image }}:{{ docker_image_tag }}"
+ failed_when: false
+ changed_when: true
+
+ - name: Log wipe completion
+ ansible.builtin.debug:
+ msg: "Application {{ web_app_name }} wiped successfully"
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..ca859ddf3c
--- /dev/null
+++ b/ansible/roles/web_app/templates/docker-compose.yml.j2
@@ -0,0 +1,15 @@
+---
+services:
+ {{ web_app_name }}:
+ image: {{ docker_image }}:{{ docker_image_tag }}
+ container_name: {{ web_app_name }}
+ ports:
+ - "{{ web_app_port }}:{{ web_app_internal_port }}"
+ restart: {{ web_app_restart_policy }}
+ environment:
+ APP_NAME: "{{ web_app_name }}"
+{% if web_app_env_vars is defined %}
+{% for key, value in web_app_env_vars.items() %}
+ {{ key }}: "{{ value }}"
+{% endfor %}
+{% endif %}
diff --git a/app_java/README.md b/app_java/README.md
new file mode 100644
index 0000000000..ae80418c1f
--- /dev/null
+++ b/app_java/README.md
@@ -0,0 +1,51 @@
+# DevOps Info Service (Java / Spring Boot)
+
+## Overview
+
+This is the compiled-language version of the DevOps Info Service implemented with Spring Boot. It mirrors the Python API and prepares the project for multi-stage Docker builds.
+
+## Prerequisites
+
+- Java 21+
+- Maven 3.9+ (for build and run commands)
+
+## Build and Run
+
+From the `app_java` directory:
+
+```bash
+mvn spring-boot:run
+```
+
+Or build a runnable JAR:
+
+```bash
+mvn clean package
+java -jar target/devops-info-service-1.0.0.jar
+```
+
+## Configuration
+
+Environment variables are mapped in `src/main/resources/application.properties`:
+
+| Variable | Default | Description |
+| --- | --- | --- |
+| `HOST` | `0.0.0.0` | Host interface to bind (`server.address`) |
+| `PORT` | `8080` | Port to listen on (`server.port`) |
+
+Examples:
+
+```bash
+PORT=9090 mvn spring-boot:run
+HOST=127.0.0.1 PORT=3000 mvn spring-boot:run
+```
+
+## API Endpoints
+
+- `GET /` - Service and system information
+- `GET /health` - Health check
+
+## Notes on Schema Parity
+
+The lab requires the same JSON structure as the Python version. To keep schema parity, the `python_version` field is still present but contains the Java runtime version (for example, `java-21`).
+
diff --git a/app_java/docs/Java.md b/app_java/docs/Java.md
new file mode 100644
index 0000000000..350560e6e5
--- /dev/null
+++ b/app_java/docs/Java.md
@@ -0,0 +1,10 @@
+# Why Java and Spring Boot for the Compiled Version
+
+Spring Boot is a practical choice for DevOps-oriented services:
+- It is widely used in industry and integrates well with enterprise tooling.
+- It offers strong defaults for web APIs, JSON serialization, and configuration.
+- It scales from small labs to production-ready services without rewrites.
+- It works naturally with Docker and Kubernetes (health checks, ports, env vars).
+
+For this lab, Spring Boot keeps the code clear while still being realistic.
+
diff --git a/app_java/docs/LAB01.md b/app_java/docs/LAB01.md
new file mode 100644
index 0000000000..b11fc64de1
--- /dev/null
+++ b/app_java/docs/LAB01.md
@@ -0,0 +1,55 @@
+# Lab 01 - DevOps Info Service (Java / Spring Boot)
+
+## Implementation Notes
+
+The compiled-language version is implemented with Spring Boot and mirrors the Python API:
+- `GET /`
+- `GET /health`
+
+Key implementation files:
+- `app_java/src/main/java/com/devopsinfo/DevopsInfoServiceApplication.java`
+- `app_java/src/main/java/com/devopsinfo/api/InfoController.java`
+- `app_java/src/main/java/com/devopsinfo/service/InfoService.java`
+- `app_java/src/main/java/com/devopsinfo/api/RestExceptionHandler.java`
+
+## Configuration
+
+Environment variables are wired through `application.properties`:
+
+```properties
+server.address=${HOST:0.0.0.0}
+server.port=${PORT:8080}
+```
+
+This preserves the lab requirement to configure the app via `HOST` and `PORT`.
+
+## Build and Run
+
+From the `app_java` directory:
+
+```bash
+mvn spring-boot:run
+curl http://127.0.0.1:8080/
+curl http://127.0.0.1:8080/health
+```
+
+Or build a runnable JAR:
+
+```bash
+mvn clean package
+java -jar target/devops-info-service-1.0.0.jar
+```
+
+## Schema Parity with Python
+
+The lab asks for the same JSON structure as the Python service. To keep parity, the `python_version` field is still present but contains the Java runtime version (for example, `java-21`).
+
+## Screenshots
+
+Screenshots directory:
+- `app_java/docs/screenshots/01-main-endpoint.png`
+- `app_java/docs/screenshots/02-health-check.png`
+- `app_java/docs/screenshots/03-formatted-output.png`
+
+Replace the placeholder images with real screenshots from your environment.
+
diff --git a/app_java/docs/screenshots/01-main-endpoint.png b/app_java/docs/screenshots/01-main-endpoint.png
new file mode 100644
index 0000000000..39210020c0
Binary files /dev/null and b/app_java/docs/screenshots/01-main-endpoint.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..51ce4e8c3a
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..597757af1d
Binary files /dev/null and b/app_java/docs/screenshots/03-formatted-output.png differ
diff --git a/app_java/pom.xml b/app_java/pom.xml
new file mode 100644
index 0000000000..cfc5be0196
--- /dev/null
+++ b/app_java/pom.xml
@@ -0,0 +1,40 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.0
+
+
+
+ com.devopsinfo
+ devops-info-service
+ 1.0.0
+ devops-info-service
+ DevOps course info service (Spring Boot)
+
+
+ 21
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
+
diff --git a/app_java/src/main/java/com/devopsinfo/DevopsInfoServiceApplication.java b/app_java/src/main/java/com/devopsinfo/DevopsInfoServiceApplication.java
new file mode 100644
index 0000000000..77de372f97
--- /dev/null
+++ b/app_java/src/main/java/com/devopsinfo/DevopsInfoServiceApplication.java
@@ -0,0 +1,13 @@
+package com.devopsinfo;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class DevopsInfoServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(DevopsInfoServiceApplication.class, args);
+ }
+}
+
diff --git a/app_java/src/main/java/com/devopsinfo/api/InfoController.java b/app_java/src/main/java/com/devopsinfo/api/InfoController.java
new file mode 100644
index 0000000000..f435b544f6
--- /dev/null
+++ b/app_java/src/main/java/com/devopsinfo/api/InfoController.java
@@ -0,0 +1,29 @@
+package com.devopsinfo.api;
+
+import com.devopsinfo.api.dto.HealthResponse;
+import com.devopsinfo.api.dto.InfoResponse;
+import com.devopsinfo.service.InfoService;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class InfoController {
+
+ private final InfoService infoService;
+
+ public InfoController(InfoService infoService) {
+ this.infoService = infoService;
+ }
+
+ @GetMapping("/")
+ public InfoResponse index(HttpServletRequest request) {
+ return infoService.buildInfoResponse(request);
+ }
+
+ @GetMapping("/health")
+ public HealthResponse health() {
+ return infoService.buildHealthResponse();
+ }
+}
+
diff --git a/app_java/src/main/java/com/devopsinfo/api/RequestLoggingFilter.java b/app_java/src/main/java/com/devopsinfo/api/RequestLoggingFilter.java
new file mode 100644
index 0000000000..dc99564830
--- /dev/null
+++ b/app_java/src/main/java/com/devopsinfo/api/RequestLoggingFilter.java
@@ -0,0 +1,33 @@
+package com.devopsinfo.api;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+@Component
+public class RequestLoggingFilter extends OncePerRequestFilter {
+
+ private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain
+ ) throws ServletException, IOException {
+ String remoteAddr = request.getRemoteAddr();
+ if (remoteAddr == null || remoteAddr.isBlank()) {
+ remoteAddr = "unknown";
+ }
+
+ log.info("Request received: {} {} from {}", request.getMethod(), request.getRequestURI(), remoteAddr);
+ filterChain.doFilter(request, response);
+ }
+}
+
diff --git a/app_java/src/main/java/com/devopsinfo/api/RestExceptionHandler.java b/app_java/src/main/java/com/devopsinfo/api/RestExceptionHandler.java
new file mode 100644
index 0000000000..f573b11d01
--- /dev/null
+++ b/app_java/src/main/java/com/devopsinfo/api/RestExceptionHandler.java
@@ -0,0 +1,44 @@
+package com.devopsinfo.api;
+
+import jakarta.servlet.http.HttpServletRequest;
+import java.util.Map;
+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;
+
+@RestControllerAdvice
+public class RestExceptionHandler {
+
+ private static final Logger log = LoggerFactory.getLogger(RestExceptionHandler.class);
+
+ @ExceptionHandler(NoHandlerFoundException.class)
+ public ResponseEntity