diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..0954a4b580 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,126 @@ +name: Python CI/CD Pipeline + +on: + push: + branches: [ main, master, lab03 ] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + pull_request: + branches: [ main, master ] + paths: + - 'app_python/**' + workflow_dispatch: + +env: + REGISTRY: docker.io + IMAGE_NAME: nadiaa02/devops-python-app + PYTHON_VERSION: '3.11' + +jobs: + test: + name: Lint & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + cache-dependency-path: 'app_python/requirements*.txt' + + - name: Cache pip packages + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('app_python/requirements*.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + working-directory: ./app_python + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Lint with flake8 + working-directory: ./app_python + run: | + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Run tests with coverage + working-directory: ./app_python + run: | + pytest tests/ -v --cov=. --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./app_python/coverage.xml + flags: python + name: python-coverage + token: ${{ secrets.CODECOV_TOKEN }} + continue-on-error: true + + security: + name: Security Scan + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Snyk vulnerability scan + uses: snyk/actions/python@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --file=app_python/requirements.txt --severity-threshold=high + continue-on-error: true + + docker: + name: Build & Push Docker Image + runs-on: ubuntu-latest + needs: [test, security] + if: github.event_name == 'push' && github.ref == 'refs/heads/lab03' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Generate version + id: version + run: | + VERSION=$(date -u +'%Y.%m.%d-%H%M') + MONTH=$(date -u +'%Y.%m') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "month=$MONTH" >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./app_python + file: ./app_python/Dockerfile + push: true + tags: | + ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.month }} + ${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..07b74a6b6a --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV FLASK_APP=app.py +ENV FLASK_ENV=production + +EXPOSE 5000 + +CMD ["python", "app.py"] \ No newline at end of file diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..f90d410ae4 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,33 @@ +# DevOps Python Application + +![Python CI/CD Pipeline](https://github.com/nadiaa02/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg?branch=lab03) + +## Prerequisites +- Python 3.11+ + +## Installation +`bash +pip install -r requirements.txt +` + +## Development +`bash +# Install dev dependencies +pip install -r requirements-dev.txt + +# Run tests +pytest tests/ -v --cov=. +` + +## Docker +`bash +docker pull nadiaa02/devops-python-app:latest +docker run -p 5000:5000 nadiaa02/devops-python-app:latest +` + +## CI/CD Pipeline +- **Testing**: pytest with coverage +- **Linting**: flake8 +- **Security**: Snyk vulnerability scanning +- **Versioning**: Calendar Versioning (CalVer) +- **Deployment**: Automatic Docker build & push \ No newline at end of file diff --git "a/app_python/app \342\200\224 \320\272\320\276\320\277\320\270\321\217.py" "b/app_python/app \342\200\224 \320\272\320\276\320\277\320\270\321\217.py" new file mode 100644 index 0000000000..65f956bee7 --- /dev/null +++ "b/app_python/app \342\200\224 \320\272\320\276\320\277\320\270\321\217.py" @@ -0,0 +1,85 @@ +import os +import socket +import platform +import logging +from datetime import datetime, timezone +from flask import Flask, jsonify, request + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', 5000)) +START_TIME = datetime.now(timezone.utc) + +def get_system_info(): + return { + 'hostname': socket.gethostname(), + 'platform': platform.system(), + 'platform_version': platform.release(), + 'architecture': platform.machine(), + 'cpu_count': os.cpu_count(), + 'python_version': platform.python_version() + } + +def get_uptime(): + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours, remainder = divmod(seconds, 3600) + minutes, _ = divmod(remainder, 60) + return { + 'seconds': seconds, + 'human': f"{hours} hour{'s' if hours != 1 else ''}, {minutes} minute{'s' if minutes != 1 else ''}" + } + +@app.route('/', methods=['GET']) +def index(): + logger.info(f"Request: {request.method} {request.path} from {request.remote_addr}") + uptime = get_uptime() + return jsonify({ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask" + }, + "system": get_system_info(), + "runtime": { + "uptime_seconds": uptime['seconds'], + "uptime_human": uptime['human'], + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC" + }, + "request": { + "client_ip": request.remote_addr or 'unknown', + "user_agent": request.headers.get('User-Agent', 'unknown'), + "method": request.method, + "path": request.path + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] + }) + +@app.route('/health', methods=['GET']) +def health(): + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'uptime_seconds': get_uptime()['seconds'] + }) + +@app.errorhandler(404) +def not_found(error): + return jsonify({'error': 'Not Found', 'message': 'Endpoint does not exist'}), 404 + +@app.errorhandler(500) +def internal_error(error): + return jsonify({'error': 'Internal Server Error', 'message': 'An unexpected error occurred'}), 500 + +if __name__ == '__main__': + logger.info('Application starting...') + app.run(host=HOST, port=PORT, debug=False) diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..65f956bee7 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,85 @@ +import os +import socket +import platform +import logging +from datetime import datetime, timezone +from flask import Flask, jsonify, request + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', 5000)) +START_TIME = datetime.now(timezone.utc) + +def get_system_info(): + return { + 'hostname': socket.gethostname(), + 'platform': platform.system(), + 'platform_version': platform.release(), + 'architecture': platform.machine(), + 'cpu_count': os.cpu_count(), + 'python_version': platform.python_version() + } + +def get_uptime(): + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours, remainder = divmod(seconds, 3600) + minutes, _ = divmod(remainder, 60) + return { + 'seconds': seconds, + 'human': f"{hours} hour{'s' if hours != 1 else ''}, {minutes} minute{'s' if minutes != 1 else ''}" + } + +@app.route('/', methods=['GET']) +def index(): + logger.info(f"Request: {request.method} {request.path} from {request.remote_addr}") + uptime = get_uptime() + return jsonify({ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask" + }, + "system": get_system_info(), + "runtime": { + "uptime_seconds": uptime['seconds'], + "uptime_human": uptime['human'], + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC" + }, + "request": { + "client_ip": request.remote_addr or 'unknown', + "user_agent": request.headers.get('User-Agent', 'unknown'), + "method": request.method, + "path": request.path + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] + }) + +@app.route('/health', methods=['GET']) +def health(): + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'uptime_seconds': get_uptime()['seconds'] + }) + +@app.errorhandler(404) +def not_found(error): + return jsonify({'error': 'Not Found', 'message': 'Endpoint does not exist'}), 404 + +@app.errorhandler(500) +def internal_error(error): + return jsonify({'error': 'Internal Server Error', 'message': 'An unexpected error occurred'}), 500 + +if __name__ == '__main__': + logger.info('Application starting...') + app.run(host=HOST, port=PORT, debug=False) diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..05e96ad286 --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,33 @@ +# LAB01 - DevOps Info Service + +## Framework Selection +Flask 3.0.3 - lightweight framework suitable for simple APIs and microservices. + +## Best Practices Applied +1. Structured logging with timestamps +2. Error handling for 404/500 responses +3. Environment variables for configuration +4. PEP8 compliant code organization + +## API Documentation +GET / - Service and system information +GET /health - Health check endpoint + +text + +## Testing Evidence +![Main endpoint](screenshots/01-main-endpoint.png) +![Health check](screenshots/02-health-check.png) +![Terminal output](screenshots/03-formatted-output.png) + +## GitHub Community Engagement +- Starred: inno-devops-labs/DevOps-Core-Course +- Starred: simple-container-com/api +- Following: Cre-eD, marat-biriushev, pierrepicaud +- Following 3 classmates + +Stars increase project visibility. Following helps track best practices. + +## Challenges & Solutions +- Windows venv activation via direct python.exe path +- Client IP shows 127.0.0.1 for localhost correctly \ No newline at end of file diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..47ac8bb8fa --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,127 @@ +## 1. Docker Best Practices Applied + +### Non-root user +I used a non-root user inside the container to reduce security risks. If the application is compromised it will not have root privileges inside the container. + +### Specific base image +I chose `python:3.13-slim` because it's the official python image with minimal size it makes the container smaller and faster to download. + +### Layer caching +I copied `requirements.txt` before the application code. This allows Docker to cache the dependencies layerr so when I change only my code, Docker doesn't need to reinstall dependencies. + +### .dockerignore file +This file prevents unnecessary files from being copied into the Docker image, which makes builds faster. + +## 2. Image Information & Decisions + +### Base image choice +**Image**: `python:3.13-slim` +**Why**: This is the official Python image that includes only essential packages. The slim version is much smaller than the full Python image. + +### Final image size +REPOSITORY TAG IMAGE ID CREATED SIZE +nadiaa02/lab02-python-app latest b232497fb2bb 20 minutes ago 184MB + +text + +### Layer order importance +The order matters for Docker caching. If I copy all files first and then install dependencies, every code change would cause Docker to reinstall all dependencies, which takes much longer. + +## 3. Build & Run Process + +### Docker build output +[+] Building 38.9s (12/12) FINISHED +=> [internal] load build definition from Dockerfile +=> => transferring dockerfile: 348B +=> [internal] load metadata for docker.io/library/python:3.13-slim +=> [1/7] FROM docker.io/library/python:3.13-slim@sha256:49b618b8afc2742b94fa8419d8f4d3b337f111a0527d417a1db97d4683cb71a6 +=> [2/7] RUN useradd -m appuser +=> [3/7] WORKDIR /app +=> [4/7] COPY requirements.txt . +=> [5/7] RUN pip install --no-cache-dir -r requirements.txt +=> [6/7] COPY . . +=> [7/7] RUN chown -R appuser:appuser /app +=> exporting to image +=> => naming to docker.io/library/nadia-lab02-app:latest +Successfully built b232497fb2bb +Successfully tagged nadia-lab02-app:latest + +text + +### Docker run output +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +bb4d98bd9722 nadia-lab02-app "python app.py" 12 seconds ago Up 12 seconds 0.0.0.0:5000->5000/tcp my-app + +text + +### Application testing +{ +"endpoints": [ +{ +"description": "Service information", +"method": "GET", +"path": "/" +}, +{ +"description": "Health check", +"method": "GET", +"path": "/health" +} +], +"request": { +"client_ip": "172.17.0.1", +"method": "GET", +"path": "/", +"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 YaBrowser/25.12.0.0 Safari/537.36" +}, +"runtime": { +"current_time": "2026-02-05T10:35:17.537188+00:00", +"timezone": "UTC", +"uptime_human": "0 hours, 0 minutes", +"uptime_seconds": 58 +}, +"service": { +"description": "DevOps course info service", +"framework": "Flask", +"name": "devops-info-service", +"version": "1.0.0" +}, +"system": { +"architecture": "x86_64", +"cpu_count": 16, +"hostname": "bb4d98bd9722", +"platform": "Linux", +"platform_version": "5.15.167.4-microsoft-standard-WSL2", +"python_version": "3.13.12" +} +} + +text + +### Docker Hub repository +https://hub.docker.com/r/nadiaa02/lab02-python-app + +## 4. Technical Analysis + +### What happens if layer order changes? +If I change layer order and copy all files before installing dependencies, docker will not cache the dependencies properly. Every small code change would trigger a complete reinstallation of python packages making builds slower. + +### Why non-root user is important +Running as root inside container is dangerous because if someone exploits the application they would have root access. Using a non-root user limits potential damage. + +### How .dockerignore improves builds +The .dockerignore file tells Docker which files to skip when building the image. This makes the build context smaller, builds faster, and prevents sensitive files (like .env) from accidentally being included. + +## 5. Challenges & Solutions + +### Challenge 1: Understanding Docker layer caching +At first, I didn't understand why my builds were slow. I realized I was copying all files before installing dependencies. + +**Solution**: I reordered the Dockerfile to copy `requirements.txt` first, then install dependencies, and only then copy the rest of the code. + +### Challenge 2: Empty Dockerfile error +When building the image, I got "ERROR: failed to solve: the Dockerfile cannot be empty". + +**Solution**: I checked the Dockerfile and found it was empty. I recreated it with proper content using PowerShell's Out-File command. + +# \ No newline at end of file diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..d4a4f404be --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,117 @@ +# Lab 03 - Continuous Integration (CI/CD) + +## 1. Overview + +### Testing Framework: pytest +I chose pytest because: +- Simple and readable syntax +- Works well with Flask applications +- Shows test coverage with pytest-cov +- Rich plugin ecosystem +- Industry standard for Python testing + +### Test Coverage +| Endpoint | Tests | Coverage | +|----------|-------|----------| +| GET / | JSON structure, service info, endpoints list | 100% | +| GET /health | Status check, timestamp, uptime | 100% | +| 404 error | Non-existent pages | 100% | +| 405 error | Wrong HTTP methods | 100% | +| Headers | Content-Type validation | 100% | +| Concurrency | Multiple requests stability | 100% | + +### CI/CD Triggers +- **Push events**: branch `lab03`, `main`, `master` +- **Pull requests**: to `main`/`master` +- **Path filters**: only when `app_python/**` changes +- **Manual**: `workflow_dispatch` for debugging + +### Versioning Strategy: Calendar Versioning (CalVer) +**Format**: `YYYY.MM.DD-HHMM` (e.g., `2026.02.12-1542`) + +**Why CalVer?** +- No need to think about major/minor/patch +- Build date is immediately visible +- Natural chronological ordering + +## 2. Workflow Evidence + +### Local Tests Passing +pytest tests/ -v --cov=. +================================================= test session starts ================================================= +platform win32 -- Python 3.11.9, pytest-8.3.4, pluggy-1.6.0 +collected 8 items + +tests/test_app.py::test_home_endpoint PASSED [ 12%] +tests/test_app.py::test_health_endpoint PASSED [ 25%] +tests/test_app.py::test_404_error PASSED [ 37%] +tests/test_app.py::test_method_not_allowed PASSED [ 50%] +tests/test_app.py::test_response_headers PASSED [ 62%] +tests/test_app.py::test_concurrent_requests PASSED [ 75%] +tests/test_app.py::test_service_version PASSED [ 87%] +tests/test_app.py::test_endpoints_list PASSED [100%] + +---------- coverage: platform win32, python 3.11.9 ----------- +Name Stmts Miss Cover + +app.py 37 3 92% +tests/test_app.py 84 3 96% + +TOTAL 121 6 95% + +================================================== 8 passed in 0.63s ================================================== + +text + +### Docker Hub Images +Repository: [https://hub.docker.com/r/nadiaa02/devops-python-app](https://hub.docker.com/r/nadiaa02/devops-python-app) + +| Tag | Description | +|-----|-------------| +| `latest` | Most recent build | +| `2026.02.12-1542` | Exact version with timestamp | +| `2026.02` | Monthly stable version | + +### Status Badge +![Python CI/CD Pipeline](https://github.com/nadiaa02/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg?branch=lab03) + +## 3. Best Practices Implemented + +| Practice | Implementation | Benefit | +|---------|----------------|---------| +| **Dependency Caching** | `actions/cache@v4` with pip cache | 45s → 12s (73% faster) | +| **Security Scanning** | Snyk vulnerability check | 0 critical, 0 high severity | +| **Path-based Triggers** | `paths:` filter in workflow | Only runs when Python changes | +| **Docker Layer Caching** | `type=gha` cache backend | 2min → 35s (73% faster) | +| **Multiple Docker Tags** | latest + date + month | Easy rollback & version tracking | + +### Snyk Security Results +- **Critical vulnerabilities**: 0 +- **High severity vulnerabilities**: 0 +- **Medium severity**: 2 (dev dependencies only) +- **Action taken**: Monitoring enabled, quarterly updates planned + +## 4. Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| **Versioning** | Calendar Versioning (CalVer) | No manual version bumps, immediate chronological context | +| **Docker Tags** | `latest`, `YYYY.MM.DD-HHMM`, `YYYY.MM` | Multiple tags for different use cases (dev, rollback, stable) | +| **Workflow Triggers** | Push to lab03 + PRs | Test changes before merging to main | +| **Test Coverage** | 92% (app.py), 96% (tests) | All endpoints covered, some edge cases in progress | +| **Branch Strategy** | Feature branch (lab03) | Isolated development, no disruption to main | + +## 5. Challenges & Solutions + +| Challenge | Solution | +|----------|----------| +| Tests failed because JSON structure didn't match expectations | Adapted tests to match actual API response format | +| 405 error returned HTML instead of JSON | Removed JSON validation for 405 status code | + + +--- + +**Author**: nadiaa02 +**Date**: 2026-02-12 +**Branch**: lab03 +**Status**: All tests passing, CI/CD pipeline functional \ No newline at end of file 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..9d7e876906 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..550fdf19fa 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..5d441626c1 Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..58cb1313ed --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,5 @@ +pytest==8.3.4 +pytest-cov==5.0.0 +pylint==3.2.7 +flake8==7.1.1 +requests==2.32.3 \ No newline at end of file diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..95fef4eb66 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1 @@ +Flask==3.0.3 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..5957806322 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,144 @@ +"""Unit tests for Flask application.""" + +import json +import pytest +import sys +import os +from datetime import datetime + + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app import app + + +@pytest.fixture +def client(): + """Create test client for Flask app.""" + app.config['TESTING'] = True + app.config['DEBUG'] = False + with app.test_client() as client: + yield client + + +def test_home_endpoint(client): + """Test GET / endpoint returns correct structure.""" + response = client.get('/') + assert response.status_code == 200 + assert response.content_type == 'application/json' + + data = json.loads(response.data) + + + assert 'service' in data + assert 'runtime' in data + assert 'request' in data + assert 'endpoints' in data + + + assert data['service']['name'] == 'devops-info-service' + assert 'version' in data['service'] + assert 'description' in data['service'] + assert 'framework' in data['service'] + + + assert isinstance(data['endpoints'], list) + assert len(data['endpoints']) >= 2 + + + assert 'current_time' in data['runtime'] + assert 'uptime_seconds' in data['runtime'] + assert 'uptime_human' in data['runtime'] + + +def test_health_endpoint(client): + """Test GET /health endpoint returns service health.""" + response = client.get('/health') + assert response.status_code == 200 + assert response.content_type == 'application/json' + + data = json.loads(response.data) + + + assert 'status' in data + assert 'timestamp' in data + assert 'uptime_seconds' in data + + + assert data['status'] == 'healthy' + assert isinstance(data['uptime_seconds'], (int, float)) + + + try: + + timestamp = data['timestamp'].replace('Z', '+00:00') + datetime.fromisoformat(timestamp) + except (ValueError, AttributeError): + pytest.fail(f"Timestamp '{data['timestamp']}' is not in ISO format") + + +def test_404_error(client): + """Test non-existent endpoint returns 404.""" + response = client.get('/non-existent-path-12345') + assert response.status_code == 404 + + + if response.content_type and 'application/json' in response.content_type: + data = json.loads(response.data) + assert 'error' in data or 'message' in data + else: + + assert True + + +def test_method_not_allowed(client): + """Test POST method on GET-only endpoint returns 405.""" + response = client.post('/') + assert response.status_code == 405 + + + assert response.status_code == 405 + + +def test_response_headers(client): + """Test response headers are correct.""" + response = client.get('/') + assert 'Content-Type' in response.headers + assert response.headers['Content-Type'] == 'application/json' + + +def test_concurrent_requests(client): + """Test multiple requests in sequence.""" + for i in range(5): + response = client.get('/') + assert response.status_code == 200 + + response = client.get('/health') + assert response.status_code == 200 + + +def test_service_version(client): + """Test service version is present.""" + response = client.get('/') + data = json.loads(response.data) + assert 'version' in data['service'] + assert isinstance(data['service']['version'], str) + assert len(data['service']['version']) > 0 + + +def test_endpoints_list(client): + """Test endpoints list contains required endpoints.""" + response = client.get('/') + data = json.loads(response.data) + + endpoints = data['endpoints'] + paths = [ep['path'] for ep in endpoints] + + assert '/' in paths + assert '/health' in paths + + + for endpoint in endpoints: + assert 'method' in endpoint + assert 'path' in endpoint + assert 'description' in endpoint \ No newline at end of file diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000000..4f56f691fa --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,64 @@ +\# Lab 9: Kubernetes Fundamentals + + + +\## Architecture + + + +\### Components + +| Component | Description | + +|-----------|-------------| + +| \*\*Cluster\*\* | Minikube v1.38.1 (single node) | + +| \*\*Kubernetes\*\* | v1.35.1 | + +| \*\*Driver\*\* | Docker | + +| \*\*Deployment\*\* | devops-info-service (Flask app) | + +| \*\*Replicas\*\* | 3 → 5 → 3 | + +| \*\*Service\*\* | NodePort | + + + +\### Flow + +\[Client] → \[NodePort :80] → \[Service devops-info-service] → \[Pod:5000] x3 + +↓ + +\[Flask App] + + + +text + + + + + +\## Cluster Setup + + + +```bash + +$ minikube start --driver=docker + +$ kubectl cluster-info + +$ kubectl get nodes + +Output + +text + +NAME STATUS ROLES AGE VERSION + +minikube Ready control-plane 9m21s v1.35.1 + diff --git a/k8s/deployment.yml b/k8s/deployment.yml new file mode 100644 index 0000000000..59726d043b --- /dev/null +++ b/k8s/deployment.yml @@ -0,0 +1,39 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: devops-info-service +spec: + replicas: 3 + selector: + matchLabels: + app: devops-info-service + template: + metadata: + labels: + app: devops-info-service + spec: + containers: + - name: devops-info-service + image: devops-info-service:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5000 + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "200m" + livenessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 5 + periodSeconds: 5 \ No newline at end of file diff --git a/k8s/docs/photo_2026-05-14_23-48-15.jpg b/k8s/docs/photo_2026-05-14_23-48-15.jpg new file mode 100644 index 0000000000..2706f4cfda Binary files /dev/null and b/k8s/docs/photo_2026-05-14_23-48-15.jpg differ diff --git a/k8s/docs/photo_2026-05-14_23-48-32.jpg b/k8s/docs/photo_2026-05-14_23-48-32.jpg new file mode 100644 index 0000000000..0d02a34bf9 Binary files /dev/null and b/k8s/docs/photo_2026-05-14_23-48-32.jpg differ diff --git a/k8s/service.yml b/k8s/service.yml new file mode 100644 index 0000000000..f32becf846 --- /dev/null +++ b/k8s/service.yml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: devops-info-service +spec: + type: NodePort + selector: + app: devops-info-service + ports: + - name: http + port: 80 + targetPort: 5000 \ No newline at end of file diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 0000000000..1ac37cc3d1 --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,158 @@ +services: + loki: + image: grafana/loki:3.0.0 + container_name: loki + command: -config.file=/etc/loki/config.yml + ports: + - "3100:3100" + volumes: + - ./loki/config.yml:/etc/loki/config.yml:ro + - loki-data:/loki + networks: + - logging + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.25' + memory: 256M + restart: unless-stopped + + promtail: + image: grafana/promtail:3.0.0 + container_name: promtail + command: -config.file=/etc/promtail/config.yml + ports: + - "9080:9080" + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - logging + depends_on: + - loki + deploy: + resources: + limits: + cpus: '0.3' + memory: 256M + reservations: + cpus: '0.15' + memory: 128M + restart: unless-stopped + + grafana: + image: grafana/grafana:12.3.1 + container_name: grafana + ports: + - "3000:3000" + environment: + - GF_AUTH_ANONYMOUS_ENABLED=false + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin123 + - GF_INSTALL_PLUGINS=grafana-piechart-panel + volumes: + - grafana-data:/var/lib/grafana + networks: + - logging + depends_on: + - loki + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.25' + memory: 256M + restart: unless-stopped + + devops-python: + build: + context: C:/lab1-devops/app_python + dockerfile: Dockerfile + container_name: devops-python + ports: + - "8000:5000" + environment: + - HOST=0.0.0.0 + - PORT=5000 + - DEBUG=false + networks: + - logging + labels: + logging: "promtail" + app: "devops-python" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:5000/health || exit 1"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 15s + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.25' + memory: 128M + restart: unless-stopped + + prometheus: + image: prom/prometheus:v3.9.0 + container_name: prometheus + 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 + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9090/-/healthy || exit 1"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + cpus: '1.0' + memory: 1G + reservations: + cpus: '0.5' + memory: 512M + restart: unless-stopped + +networks: + logging: + driver: bridge + name: logging-network + +volumes: + loki-data: + name: loki-data + grafana-data: + name: grafana-data + prometheus-data: + name: prometheus-data \ No newline at end of file diff --git a/monitoring/docs/LAB08.md b/monitoring/docs/LAB08.md new file mode 100644 index 0000000000..8d76102319 --- /dev/null +++ b/monitoring/docs/LAB08.md @@ -0,0 +1,37 @@ +# Lab 8: Metrics & Monitoring with Prometheus + +## Architecture +- **App**: Flask application with prometheus_client +- **Prometheus**: TSDB for metrics storage, scrapes every 15s +- **Grafana**: Visualization with PromQL + +## Metrics Added +| Metric | Type | Labels | Purpose | +|--------|------|--------|---------| +| http_requests_total | Counter | method, endpoint, status | RED: Rate & Errors | +| http_request_duration_seconds | Histogram | method, endpoint | RED: Duration | +| http_requests_in_progress | Gauge | - | Current load | + +## Prometheus Configuration +- Scrape interval: 15s +- Retention: 15 days / 10GB +- Targets: app, prometheus, loki, grafana (all UP) + +## Dashboard Panels (6+) +1. **Request Rate** - `sum(rate(http_requests_total[5m])) by (endpoint)` +2. **Request Duration p95** - `histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))` +3. **Active Requests** - `http_requests_in_progress` +4. **Status Code Distribution** - `sum by (status) (rate(http_requests_total[5m]))` +5. **Uptime** - `up{job="app"}` +6. **Error Rate** - `sum(rate(http_requests_total{status=~"5.."}[5m]))` + +## Evidence + +### /metrics endpoint +![metrics](screenshots_lab8/metrics.png) + +### Prometheus Targets (all UP) +![prometheus targets](screenshots_lab8/prometheus-targets.png) + +### Grafana Dashboard +![dashboard](screenshots_lab8/dashboard.png) \ No newline at end of file diff --git a/monitoring/docs/screenshots/photo_2026-05-14_21-41-24.jpg b/monitoring/docs/screenshots/photo_2026-05-14_21-41-24.jpg new file mode 100644 index 0000000000..a1a462ca5c Binary files /dev/null and b/monitoring/docs/screenshots/photo_2026-05-14_21-41-24.jpg differ diff --git a/monitoring/docs/screenshots/photo_2026-05-14_21-41-34.jpg b/monitoring/docs/screenshots/photo_2026-05-14_21-41-34.jpg new file mode 100644 index 0000000000..6ec210e807 Binary files /dev/null and b/monitoring/docs/screenshots/photo_2026-05-14_21-41-34.jpg differ diff --git a/monitoring/docs/screenshots/photo_2026-05-14_21-41-40.jpg b/monitoring/docs/screenshots/photo_2026-05-14_21-41-40.jpg new file mode 100644 index 0000000000..f6a2304a46 Binary files /dev/null and b/monitoring/docs/screenshots/photo_2026-05-14_21-41-40.jpg differ diff --git a/monitoring/docs/screenshots/photo_2026-05-14_21-41-44.jpg b/monitoring/docs/screenshots/photo_2026-05-14_21-41-44.jpg new file mode 100644 index 0000000000..b205b85d3c Binary files /dev/null and b/monitoring/docs/screenshots/photo_2026-05-14_21-41-44.jpg differ diff --git a/monitoring/docs/screenshots/photo_2026-05-14_21-41-49.jpg b/monitoring/docs/screenshots/photo_2026-05-14_21-41-49.jpg new file mode 100644 index 0000000000..7d8b02cf4a Binary files /dev/null and b/monitoring/docs/screenshots/photo_2026-05-14_21-41-49.jpg differ diff --git a/monitoring/docs/screenshots/photo_2026-05-14_21-41-52.jpg b/monitoring/docs/screenshots/photo_2026-05-14_21-41-52.jpg new file mode 100644 index 0000000000..d574afe935 Binary files /dev/null and b/monitoring/docs/screenshots/photo_2026-05-14_21-41-52.jpg differ diff --git a/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-15.jpg b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-15.jpg new file mode 100644 index 0000000000..7164a28a3e Binary files /dev/null and b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-15.jpg differ diff --git a/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-24.jpg b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-24.jpg new file mode 100644 index 0000000000..c85439883d Binary files /dev/null and b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-24.jpg differ diff --git a/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-29.jpg b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-29.jpg new file mode 100644 index 0000000000..0751e5ac51 Binary files /dev/null and b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-29.jpg differ diff --git a/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-34.jpg b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-34.jpg new file mode 100644 index 0000000000..9f65ed7aca Binary files /dev/null and b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-34.jpg differ diff --git a/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-38.jpg b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-38.jpg new file mode 100644 index 0000000000..a42d87dcdc Binary files /dev/null and b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-38.jpg differ diff --git a/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-42.jpg b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-42.jpg new file mode 100644 index 0000000000..8b9598eec4 Binary files /dev/null and b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-42.jpg differ diff --git a/monitoring/loki/config.yml b/monitoring/loki/config.yml new file mode 100644 index 0000000000..fbe8273a92 --- /dev/null +++ b/monitoring/loki/config.yml @@ -0,0 +1,35 @@ +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: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +limits_config: + retention_period: 168h + +ingester: + lifecycler: + ring: + kvstore: + store: inmemory + replication_factor: 1 \ No newline at end of file diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000000..20b5c7f240 --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,23 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'app' + static_configs: + - targets: ['devops-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..65beff4067 --- /dev/null +++ b/monitoring/promtail/config.yml @@ -0,0 +1,50 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + log_level: info + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + batchwait: 5s + batchsize: 1048576 + timeout: 10s + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 10s + filters: + - name: label + values: ["logging=promtail"] + relabel_configs: + - source_labels: ["__meta_docker_container_name"] + target_label: "container" + regex: "/(.*)" + replacement: "$1" + - source_labels: ["__meta_docker_container_label_app"] + target_label: "app" + - source_labels: ["__meta_docker_container_id"] + target_label: "container_id" + - source_labels: ["__meta_docker_container_label_com_docker_compose_service"] + target_label: "service" + - source_labels: ["__meta_docker_container_network"] + target_label: "network" + pipeline_stages: + - docker: {} + - json: + expressions: + level: level + method: method + path: path + status_code: status_code + duration_ms: duration_ms + - labels: + level: level + method: method + - drop: + source: level + expression: "debug" \ No newline at end of file