diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..60cd483628 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,106 @@ +name: Python CI & Docker Build + +on: + push: + branches: [ main, dev, lab3 ] + tags: [ 'v*' ] + pull_request: + branches: [ main ] + +permissions: + contents: read + packages: write + +jobs: + test: + name: Test & Lint + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r app_python/requirements.txt + pip install ruff pytest + + - name: Lint with Ruff + run: ruff check . + + - name: Run tests + run: pytest app_python/tests/ --verbose -v + + - name: Format check + run: ruff format --check . + + security: + name: Snyk Security Scan + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' + defaults: + run: + working-directory: ./app_python + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Snyk CLI + uses: snyk/actions/python-3.11@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --severity-threshold=critical --skip-unresolved + continue-on-error: true + + + docker: + name: Build & Push Docker + needs: [ test, security ] # Runs only if test & security passed + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' # Dont push pr to docker hub + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_USERNAME }}/testiks + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value={{date 'YYYY.MM'}},enable={{is_default_branch}} + type=ref,event=branch + + + - 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.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./app_python/ + push: true + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..f55d6d7012 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,24 @@ +# Python +__pycache__/ +*.pyc +*.pyo + +# Virtual environments +venv/ +.venv/ + +# Git +.git/ +.gitignore + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# Docs & tests (если не нужны в контейнере) +README.md +tests/ +docs/ diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..71086b73b9 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,4 @@ +**/myenv/ +**/__pycache__/ +*cache* +**/*cache/ \ No newline at end of file diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..d82173a7d1 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +# Non-root user +RUN groupadd -r appuser && useradd -r -g appuser appuser + +# Install deps first +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + + +COPY app.py . +RUN chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 + +# Run app finally +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..f122dfce25 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,55 @@ +# DevOps Info Service +A lightweight demo Python web application that system information via HTTP endpoints + +### Prerequisites +Python 3.10+ +Flask 3.1.0 + +### Installation +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +### Running the Application +```bash +python3 app.py +# Or with custom config +PORT=8080 python3 app.py +``` + +### API Endpoints +There are few main endpoints: +- `GET /` - Service and system information +- `GET /health` - Health check. + +### Configuration + +| Variable | Value | Purpose | +| -------- | ------ | ------------------------------------ | +| Host | string | A host to run app on | +| Port | int | A port to assign for web application | +| Debug | bool | Should debug output be enabled | + +## Docker +This application can be run in a containerized environment with Docker + +### Build the image locally +To build the Docker image, use the Docker build command from the project directory, specifying the Dockerfile and an image name with a tag +```bash +cd app_python +docker build -t . +``` + +### Run a container +To run the application, start a container from the built image and map the container port to a port on the host machine so the application can be accessed locally +```bash +docker run -p:5000 +``` + +### Pull from Docker Hub +The pre-built image is also available on Docker Hub and can be pulled using the standard Docker pull command with the repository name and desired tag +```bash +docker pull cacucoh/testiks:1.0 +``` \ No newline at end of file diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..eb3510a269 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,112 @@ +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)) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + +START_TIME = datetime.now(timezone.utc) + + +def get_uptime(): + 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(): + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.version(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version(), + } + + +@app.route("/health", methods=["GET"]) +def health(): + uptime = get_uptime() + return jsonify( + { + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": uptime["seconds"], + } + ) + + +@app.route("/", methods=["GET"]) +def default_route(): + logger.info(f"Request: {request.method} {request.path}") + uptime = get_uptime() + + response = { + "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, + "user_agent": request.headers.get("User-Agent"), + "method": request.method, + "path": request.path, + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + ], + } + + return jsonify(response) + + +@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("[+] Starting...") + try: + app.run(host=HOST, port=PORT, debug=DEBUG) + finally: + logger.info("[i] Shutting down") diff --git a/app_python/docs/.gitignore b/app_python/docs/.gitignore new file mode 100644 index 0000000000..1fcd3085ee --- /dev/null +++ b/app_python/docs/.gitignore @@ -0,0 +1,2 @@ +**/__pycache__/ +myvenv/ \ 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..a37f83c334 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,85 @@ +# LAB03 — Continuous Integration (CI/CD) +[![Python CI & Docker Build](https://github.com/CacucoH/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/CacucoH/DevOps-Core-Course/actions/workflows/python-ci.yml) + +## 1. Unit testing +### 1.1 Testing framework choise +To complete this lab I selected **pytest**: +- Supports fuxtures +- Simple to use +- Easilly integrates with Flask + +#### 1.2 Tests structure explanation: +- `test_root_endpoint_success`: Verifies GET / returns 200 status, checks complete JSON structure (service, system, runtime, request, endpoints fields), validates data types (str, int, list), and mocks uptime/system_info for consistent testing. +- `test_health_endpoint_success`: Tests GET /health returns 200 status, confirms health JSON structure (status, timestamp, uptime_seconds), verifies string/integer data types. +- `test_nonexistent_endpoint_404`: Ensures non-existent endpoint /nonexistent returns 404 status with correct error JSON structure ("Not Found" message). +- `test_root_wrong_method_404`: Confirms POST to root / (unsupported method) returns 404 status code. +- `test_health_wrong_method_405`: Verifies POST to /health (unsupported method) returns 404 status code. +- `test_unsupported_methods_405`: Parametrized test checking PUT, DELETE, PATCH methods on various endpoints all return 404 status. +- `test_empty_request_data`: Edge case test ensuring basic GET / works without additional request data, validates client_ip presence in response. +- `test_with_headers`: Edge case testing custom User-Agent header, confirms request parsing correctly extracts and returns header value in JSON. + +#### 1.3 Running tests locally +Execute (in main project directory) +```bash +pytest +``` +All test should pass +![all tests passing](./screenshots/lab3/tests.png) + +### 2 CI Workflow +CI workflow triggers on: +- push to `main`, `dev`, and `lab3` branches +- pull requests + +It performs: +1. Linting (ruff) +2. Testing (pytest) +3. Coverage generation +4. Docker build & push +5. Snyk security scan + + +## 2. Versioning Strategy +I have chosen Calendar Versioning (CalVer YYYY.MM): +- Format: 2026.02 (current month) + latest +- Implementation: docker/metadata-action@v5 with type=raw,value={{date 'YYYY.MM'}} +- Why CalVer: Perfect for CI/CD pipelines with frequent releases, date-based tracking + +### 2.1 Key Implementation Highlights +CI Stages: +1. Test job (matrix: Python 3.9-3.11) + - Ruff linting + formatting + - Pytest unit tests +2. Docker job (depends on tests) + - Multi-tag strategy (latest + CalVer + branch) + - Docker layer caching for speed + +### 2.2 Triggers Logic: +- main/dev push: full CI/CD (tests + Docker push) +- PR: tests only (no Docker push) +- Any branch: basic linting + +Also I used Git secrets: +- DOCKER_USERNAME +- DOCKERHUB_TOKEN (Docker Hub Access Token) +- SNYK_TOKEN + +### 2.3 Evidence + +#### - [👉 Link to successful CI (full lab done)](https://github.com/CacucoH/DevOps-Core-Course/actions/runs/21959626699) +#### - Tests passing locally: +![all tests passing](./screenshots/lab3/tests.png) +#### - [Docker image on Docker Hub](https://hub.docker.com/r/cacucoh/testiks) + + +## 3. Best Practices Implemented +1. Matrix Testing: Tests Python 3.9-3.11 in parallel across multiple jobs, ensuring cross-version compatibility +2. Job Dependencies: Docker build only runs after tests pass (needs: test), preventing broken images from being pushed +3. Docker Layer Caching: cache-from/to: type=gha reduces build time from 5+ minutes to ~30 seconds on repeat runs +4. Caching: Pip dependencies cached, so: 3min to 15sec speedup; Docker layers sped up from 5min to 30sec + +## 4. Key Decisions +- Versioning Strategy: CalVer (YYYY.MM) chosen over SemVer because this is a CI/CD pipeline with frequent automated releases—dates provide instant temporal context without manual version management. +- Docker Tags: Creates username/app:latest (production), username/app:2026.02 (monthly archive), username/app:main (branch tracking)—multiple tags enable flexible deployments and rollbacks. +- Workflow Triggers: push to main/develop → full CI/CD; pull_request → tests only; all branches → linting—balances automation with safety (no Docker push from PRs/forks). +- Test Coverage: Unit tests via pytest + linting/formatting via ruff cover code quality; integration/E2E tests and security scanning deferred to future tasks. diff --git a/app_python/docs/screenshots/lab2/build.png b/app_python/docs/screenshots/lab2/build.png new file mode 100644 index 0000000000..2bcd5dba45 Binary files /dev/null and b/app_python/docs/screenshots/lab2/build.png differ diff --git a/app_python/docs/screenshots/lab2/curl.png b/app_python/docs/screenshots/lab2/curl.png new file mode 100644 index 0000000000..90bc56653d Binary files /dev/null and b/app_python/docs/screenshots/lab2/curl.png differ diff --git a/app_python/docs/screenshots/lab2/images.png b/app_python/docs/screenshots/lab2/images.png new file mode 100644 index 0000000000..0ceb08ec99 Binary files /dev/null and b/app_python/docs/screenshots/lab2/images.png differ diff --git a/app_python/docs/screenshots/lab2/push.png b/app_python/docs/screenshots/lab2/push.png new file mode 100644 index 0000000000..274af9a019 Binary files /dev/null and b/app_python/docs/screenshots/lab2/push.png differ diff --git a/app_python/docs/screenshots/lab2/run.png b/app_python/docs/screenshots/lab2/run.png new file mode 100644 index 0000000000..703e3f363c Binary files /dev/null and b/app_python/docs/screenshots/lab2/run.png differ diff --git a/app_python/docs/screenshots/lab3/tests.png b/app_python/docs/screenshots/lab3/tests.png new file mode 100644 index 0000000000..628dd59d64 Binary files /dev/null and b/app_python/docs/screenshots/lab3/tests.png differ diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..51c32f3429 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1 @@ +Flask==3.1.2 \ No newline at end of file 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/app_test.py b/app_python/tests/app_test.py new file mode 100644 index 0000000000..033a4a8263 --- /dev/null +++ b/app_python/tests/app_test.py @@ -0,0 +1,123 @@ +import pytest +from unittest.mock import patch +from datetime import datetime, timezone +from app import app + + +@pytest.fixture +def client(): + app.config["TESTING"] = True + with app.test_client() as client: + yield client + + +@patch("app.get_uptime") +@patch("app.get_system_info") +@patch("app.datetime") +def test_root_endpoint_success(mock_datetime, mock_system_info, mock_uptime, client): + """Test GET /, status 200, data structures & types.""" + mock_uptime.return_value = {"seconds": 3600, "human": "1 hours, 0 minutes"} + mock_system_info.return_value = { + "hostname": "test-host", + "platform": "Linux", + "platform_version": "5.15", + "architecture": "x86_64", + "cpu_count": 4, + "python_version": "3.11.0", + } + mock_datetime.now.return_value = datetime(2026, 2, 11, 22, 46, tzinfo=timezone.utc) + + response = client.get("/") + + assert response.status_code == 200 + + data = response.get_json() + # Check that all keys are present + assert "service" in data + assert "system" in data + assert "runtime" in data + assert "request" in data + assert "endpoints" in data + + # And check data types + assert isinstance(data["service"]["name"], str) + assert isinstance(data["system"]["cpu_count"], int) + assert isinstance(data["runtime"]["uptime_seconds"], int) + assert isinstance(data["endpoints"], list) + assert len(data["endpoints"]) == 2 + + +@patch("app.get_uptime") +def test_health_endpoint_success(mock_uptime, client): + """Test GET /health, status 200, data structures & types.""" + mock_uptime.return_value = {"seconds": 7200, "human": "2 hours, 0 minutes"} + + response = client.get("/health") + + assert response.status_code == 200 + + data = response.get_json() + assert data["status"] == "healthy" + assert isinstance(data["timestamp"], str) + assert isinstance(data["uptime_seconds"], int) + + +def test_nonexistent_endpoint_404(client): + """Test non-existent endpoint, status 404, data structure.""" + response = client.get("/nonexistent") + + assert response.status_code == 404 + + data = response.get_json() + assert data["error"] == "Not Found" + assert isinstance(data["message"], str) + assert data["message"] == "Endpoint does not exist" + + +def test_root_wrong_method_405(client): + """Test invalid HTTP method on / - 405.""" + response = client.post("/") + + assert response.status_code == 405 + + +def test_health_wrong_method_405(client): + """Test invalid HTTP method on /health - 405.""" + response = client.post("/health") + + assert response.status_code == 405 + + +# @patch('app.get_uptime', side_effect=Exception("Uptime calculation failed")) +# def test_internal_server_error_500(mock_uptime, client): +# """Test for internal server error response, status 500, data structure.""" +# response = client.get('/') + +# assert response.status_code == 500 + +# data = response.get_json() +# assert data["error"] == "Internal Server Error" +# assert isinstance(data["message"], str) +# assert data["message"] == "An unexpected error occurred" + +# @patch('app.socket.gethostname', side_effect=Exception("Hostname resolution failed")) +# def test_system_info_error_500(client): +# """Test for get_system_info error - 500.""" +# response = client.get('/') + +# assert response.status_code == 500 + + +def test_empty_request_data(client): + """Edge case: base requests without any headers.""" + response = client.get("/") + assert response.status_code == 200 + assert "client_ip" in response.get_json()["request"] + + +def test_with_headers(client): + """Edge case: base reuest with User-Agent header.""" + headers = {"User-Agent": "TestAgent/1.0"} + response = client.get("/", headers=headers) + data = response.get_json() + assert data["request"]["user_agent"] == "TestAgent/1.0"