diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..bf9af46eae --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,136 @@ +name: CI + +on: + push: + branches: + - master + - lab* + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + pull_request: + branches: + - master + - lab* + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + +concurrency: + group: python-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-and-lint: + name: Lint and Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + python-version: ["3.11", "3.12"] + + defaults: + run: + working-directory: app_python + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: app_python/requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run flake8 + run: flake8 . + + - name: Run tests with coverage + run: pytest --cov=. --cov-report=term --cov-report=xml --cov-fail-under=70 + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.12' + uses: codecov/codecov-action@v4 + with: + files: ./app_python/coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + + docker-build-and-push: + name: Build and Push Docker Image + needs: test-and-lint + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' + + env: + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set CalVer version + id: set-version + run: echo "VERSION=$(date +'%Y.%m.%d')" >> "$GITHUB_ENV" + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ./app_python + push: true + tags: | + ${{ env.IMAGE_NAME }}:${{ env.VERSION }} + ${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + security-scan: + name: Snyk Security Scan + needs: test-and-lint + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python (for dependency resolution) + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + working-directory: app_python + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Set up Snyk CLI + uses: snyk/actions/setup@master + + - name: Run Snyk to check for vulnerabilities + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: > + snyk test + --file=app_python/requirements.txt + --package-manager=pip + --severity-threshold=medium + --sarif-file-output=snyk.sarif + + + diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..d0495a4b4f --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,20 @@ +__pycache__/ +*.py[cod] +*.pyo +*.pyd + +venv/ +.venv/ + +.git +.gitignore + +.vscode/ +.idea/ + +tests/ +docs/ + +*.log +*.md + diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..8afbf92ddf --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +*.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..52631976d4 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,31 @@ +FROM python:3.13-slim + +# Prevent Python from writing .pyc files and enable unbuffered logs +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +# Create a non-root user and group +RUN addgroup --system app && adduser --system --ingroup app app + +# Set working directory +WORKDIR /app + +# Install dependencies first (better layer caching) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy only the application code needed at runtime +COPY app.py . + +# Document the port the app listens on +EXPOSE 5000 + +# Switch to the non-root user +USER app + +# Default environment (can be overridden at runtime) +ENV HOST=0.0.0.0 \ + PORT=5000 + +# Start the Flask application +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..4235806d12 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,270 @@ +# DevOps Info Service + +## Overview + +The DevOps Info Service is a RESTful web application built with Flask that exposes system information, runtime metrics, and health status. It's designed to be lightweight, configurable, and production-ready, with proper error handling, logging, and documentation. + +## CI/CD Status + + + +[![Python CI](https://github.com/Rash1d1/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg?branch=master)](https://github.com/Rash1d1/DevOps-Core-Course/actions/workflows/python-ci.yml) +[![Coverage](https://codecov.io/gh/Rash1d1/DevOps-Core-Course/branch/master/graph/badge.svg)](https://codecov.io/gh/Rash1d1/DevOps-Core-Course) + +## Prerequisites + +- **Python 3.11+** +- **pip** (Python package installer) +- **virtualenv** (recommended for isolated environments) + +## Installation + +1. **Clone the repository** (if applicable): + ```bash + git clone + cd app_python + ``` + +2. **Create a virtual environment** (recommended): + ```bash + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. **Install dependencies**: + ```bash + pip install -r requirements.txt + ``` + +4. **Install development tools (optional)**: + + If you are working on the code locally, you can install testing and linting tools: + + ```bash + pip install -r requirements.txt + ``` + +## Running the Application + +### Basic Usage + +Run the application with default settings (listens on `0.0.0.0:5000`): + +```bash +python app.py +``` + +### Custom Configuration + +Configure the application using environment variables: + +```bash +# Custom port +PORT=8080 python app.py + +# Custom host and port +HOST=127.0.0.1 PORT=3000 python app.py + +# Enable debug mode +DEBUG=true python app.py +``` + +### Verify Installation + +Once the application is running, test the endpoints: + +```bash +# Main endpoint +curl http://localhost:5000/ + +# Health check +curl http://localhost:5000/health + +# Pretty-printed JSON (requires jq) +curl http://localhost:5000/ | jq +``` + +## Running Tests + +This project uses **pytest** for unit tests and **pytest-cov** for coverage. + +From the `app_python/` directory: + +```bash +pytest +``` + +Run tests with coverage (the same way CI does): + +```bash +pytest --cov=. --cov-report=term --cov-report=xml +``` + +The XML report `coverage.xml` is consumed by Codecov in the CI pipeline. + +## API Endpoints + +### `GET /` + +Returns comprehensive service and system information. + +**Response:** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask" + }, + "system": { + "hostname": "my-laptop", + "platform": "Linux", + "platform_version": "Linux-6.8.0-58-generic-x86_64-with-glibc2.39", + "architecture": "x86_64", + "cpu_count": 8, + "python_version": "3.13.1" + }, + "runtime": { + "uptime_seconds": 3600, + "uptime_human": "1 hour, 0 minutes", + "current_time": "2026-01-07T14:30:00.000Z", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/7.81.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] +} +``` + +### `GET /health` + +Returns health status for monitoring and Kubernetes probes. + +**Response:** +```json +{ + "status": "healthy", + "timestamp": "2024-01-15T14:30:00.000Z", + "uptime_seconds": 3600 +} +``` + +**Status Codes:** +- `200 OK` - Service is healthy + +## Configuration + +The application can be configured using environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `HOST` | `0.0.0.0` | Host address to bind to | +| `PORT` | `5000` | Port number to listen on | +| `DEBUG` | `False` | Enable debug mode (set to `true` to enable) | + +### Examples + +```bash +# Development (localhost only) +HOST=127.0.0.1 PORT=3000 python app.py + +# Production (all interfaces) +HOST=0.0.0.0 PORT=8080 python app.py + +# Debug mode +DEBUG=true python app.py +``` + +## Project Structure + +``` +app_python/ +├── app.py # Main application +├── requirements.txt # Dependencies +├── .gitignore # Git ignore rules +├── README.md # This file +├── tests/ # Unit tests (Lab 3) +│ └── __init__.py +└── docs/ # Lab documentation + ├── LAB01.md # Lab submission + └── screenshots/ # Proof of work +``` + +## Development + +### Code Style + +This project follows PEP 8 style guidelines. Key practices: +- 4 spaces for indentation +- Maximum line length of 79 characters (soft limit 99) +- Clear function and variable names +- Docstrings for functions +- Proper import organization + +### Logging + +The application uses Python's `logging` module with INFO level by default. Logs include: +- Application startup +- Request information (method, path, client IP) +- Error details + +### Error Handling + +The application includes error handlers for: +- `404 Not Found` - Invalid endpoints +- `500 Internal Server Error` - Unexpected errors + +All errors return JSON responses for consistency. + +## Docker + +You can run this application inside a Docker container instead of managing Python and dependencies directly on your host. + +### Build Image + +Run from the `app_python/` directory: + +```bash +docker build -t /devops-info-service: . +``` + +- **`-t`**: Names/tags the image (include your Docker Hub username) +- **`.`**: Uses the current directory as the build context + +### Run Container + + +```bash +docker run \ + -p 5000:5000 \ + -e HOST=0.0.0.0 \ + -e PORT=5000 \ + /devops-info-service: +``` + +- **`-p 5000:5000`**: Maps host port 5000 to container port 5000 +- **`-e`**: Overrides environment variables if needed + +You should then be able to access: + +- Main endpoint: `http://localhost:5000/` +- Health check: `http://localhost:5000/health` + +### Pull from Docker Hub + +After pushing your image to Docker Hub, you (or anyone else) can pull and run it: + +```bash +docker pull /devops-info-service: + +docker run -p 5000:5000 /devops-info-service: +``` + diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..dcfaab0e59 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,168 @@ +""" +DevOps Info Service +Main application module +""" +import os +import socket +import platform +import logging +from datetime import datetime, timezone +from flask import Flask, jsonify, request + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +# Configuration +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', 5000)) +DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' + +# Application start time +START_TIME = datetime.now(timezone.utc) + + +def get_uptime(): + """Calculate application uptime.""" + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + human = ( + f"{hours} hour{'s' if hours != 1 else ''}, " + f"{minutes} minute{'s' if minutes != 1 else ''}" + ) + return {'seconds': seconds, 'human': human} + + +def get_system_info(): + """Collect system information.""" + return { + 'hostname': socket.gethostname(), + 'platform': platform.system(), + 'platform_version': platform.platform(), + 'architecture': platform.machine(), + 'cpu_count': os.cpu_count() or 0, + 'python_version': platform.python_version() + } + + +def get_service_info(): + """Get service metadata.""" + return { + 'name': 'devops-info-service', + 'version': '1.0.0', + 'description': 'DevOps course info service', + 'framework': 'Flask' + } + + +def get_runtime_info(): + """Get runtime information.""" + uptime = get_uptime() + current_time = ( + datetime.now(timezone.utc) + .isoformat() + .replace('+00:00', '.000Z') + ) + return { + 'uptime_seconds': uptime['seconds'], + 'uptime_human': uptime['human'], + 'current_time': current_time, + 'timezone': 'UTC', + } + + +def get_request_info(): + """Get current request information.""" + client_ip = request.remote_addr or request.environ.get( + 'HTTP_X_FORWARDED_FOR', + 'unknown', + ) + return { + 'client_ip': client_ip, + 'user_agent': request.headers.get('User-Agent', 'unknown'), + 'method': request.method, + 'path': request.path, + } + + +@app.route('/') +def index(): + """Main endpoint - service and system information.""" + logger.info( + 'Request: %s %s from %s', + request.method, + request.path, + request.remote_addr, + ) + + response = { + 'service': get_service_info(), + 'system': get_system_info(), + 'runtime': get_runtime_info(), + 'request': get_request_info(), + 'endpoints': [ + { + 'path': '/', + 'method': 'GET', + 'description': 'Service information', + }, + { + 'path': '/health', + 'method': 'GET', + 'description': 'Health check', + }, + ], + } + + return jsonify(response) + + +@app.route('/health') +def health(): + """Health check endpoint for monitoring.""" + uptime = get_uptime() + timestamp = ( + datetime.now(timezone.utc) + .isoformat() + .replace('+00:00', '.000Z') + ) + return jsonify( + { + 'status': 'healthy', + 'timestamp': timestamp, + 'uptime_seconds': uptime['seconds'], + }, + ) + + +@app.errorhandler(404) +def not_found(error): + """Handle 404 errors.""" + return jsonify( + {'error': 'Not Found', 'message': 'Endpoint does not exist'}, + ), 404 + + +@app.errorhandler(500) +def internal_error(error): + """Handle 500 errors.""" + logger.error('Internal server error: %s', error) + return jsonify( + { + 'error': 'Internal Server Error', + 'message': 'An unexpected error occurred', + }, + ), 500 + + +if __name__ == '__main__': + logger.info('Application starting...') + logger.info(f'Starting server on {HOST}:{PORT}') + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..7e274cb88d --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,352 @@ +# Lab 1 Submission: DevOps Info Service + +## Framework Selection + +### Choice: Flask + +I selected **Flask** as the web framework for this project. + +### Justification + +Flask was chosen for the following reasons: + +1. **Simplicity**: Flask's minimalistic design makes it ideal for beginners. It follows the "microframework" philosophy, providing only the essential components needed to build a web application. + +2. **Lightweight**: Flask has minimal dependencies and a small footprint, making it perfect for a simple service. + +3. **Production Ready**: Despite its simplicity, Flask is battle-tested and widely used in production environments. Many companies use Flask for microservices. + +5. **Easy to Extend**: As the course progresses and we add features (Docker, CI/CD, monitoring), Flask's extensible architecture will accommodate these additions smoothly. + +6. **Excellent Documentation**: Flask has comprehensive, beginner-friendly documentation that makes development straightforward. + +### Framework Comparison + +| Feature | Flask | FastAPI | Django | +|---------|-------|---------|--------| +| **Learning Curve** | Easy | Moderate | Steep | +| **Performance** | Good | Excellent (async) | Good | +| **Size** | Small | Small | Large | +| **Auto Documentation** | No | Yes (Swagger) | Yes (Admin) | +| **ORM Included** | No | No | Yes | +| **Flexibility** | High | High | Low (opinionated) | +| **Use Case** | Microservices, APIs | Modern APIs, async | Full web apps | +| **Best For** | Simple services | High-performance APIs | Complex web applications | + +**Why not FastAPI?** +While FastAPI offers excellent performance and automatic API documentation, it introduces async/await concepts that may be unnecessary for this simple service. Flask's synchronous model is easier to understand for beginners. + +**Why not Django?** +Django is overkill for this project. It includes an ORM, admin panel, and many features we don't need. Django's opinionated structure would add unnecessary complexity. + +## Best Practices Applied + +### 1. Clean Code Organization + +**Practice**: Clear function names, proper imports, minimal comments, PEP 8 compliance. + +**Implementation:** +```python +def get_uptime(): + """Calculate application 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} hour{'s' if hours != 1 else ''}, {minutes} minute{'s' if minutes != 1 else ''}" + } +``` + +**Import Organization:** +- Standard library imports first +- Third-party imports second +- Local imports last +- Each group separated by a blank line + +**Why it matters**: Clean code is easier to read, maintain, and debug. Following PEP 8 ensures consistency with the Python community. + +### 2. Error Handling + +**Practice**: Comprehensive error handlers for common HTTP errors. + +**Implementation:** +```python +@app.errorhandler(404) +def not_found(error): + """Handle 404 errors.""" + return jsonify({ + 'error': 'Not Found', + 'message': 'Endpoint does not exist' + }), 404 + +@app.errorhandler(500) +def internal_error(error): + """Handle 500 errors.""" + logger.error(f'Internal server error: {error}') + return jsonify({ + 'error': 'Internal Server Error', + 'message': 'An unexpected error occurred' + }), 500 +``` + +**Why it matters**: Proper error handling provides a better user experience and makes debugging easier. JSON error responses maintain API consistency. + +### 3. Logging + +**Practice**: Structured logging for application events and debugging. + +**Implementation:** +```python +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +logger.info('Application starting...') +logger.info(f'Request: {request.method} {request.path} from {request.remote_addr}') +``` + +**Why it matters**: Logging is essential for production applications. It helps track application behavior, debug issues, and monitor performance. Structured logs can be easily parsed by log aggregation tools. + +### 4. Configuration via Environment Variables + +**Practice**: Externalize configuration to make the application flexible and deployment-ready. + +**Implementation:** +```python +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', 5000)) +DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' +``` + +**Why it matters**: Environment variables allow the same code to run in different environments (development, staging, production) without code changes. This is a fundamental DevOps practice. + +### 5. Dependency Management + +**Practice**: Pin exact versions in `requirements.txt` for reproducibility. + +**Implementation:** +```txt +Flask==3.1.0 +``` + +**Why it matters**: Pinned versions ensure that all developers and deployment environments use the same dependencies, preventing "works on my machine" issues. + +### 6. Git Ignore + +**Practice**: Proper `.gitignore` to exclude unnecessary files from version control. + +**Implementation:** +```gitignore +# Python +__pycache__/ +*.py[cod] +venv/ +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +``` + +**Why it matters**: Keeps the repository clean, prevents committing sensitive information, and reduces repository size. + +## API Documentation + +### Endpoint: `GET /` + +**Description**: Returns comprehensive service and system information. + +**Request:** +```bash +curl http://localhost:5000/ +``` + +**Response:** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask" + }, + "system": { + "hostname": "my-laptop", + "platform": "Linux", + "platform_version": "Linux-6.8.0-58-generic-x86_64-with-glibc2.39", + "architecture": "x86_64", + "cpu_count": 8, + "python_version": "3.13.1" + }, + "runtime": { + "uptime_seconds": 3600, + "uptime_human": "1 hour, 0 minutes", + "current_time": "2026-01-07T14:30:00.000Z", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/7.81.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] +} +``` + +**Status Code**: `200 OK` + +### Endpoint: `GET /health` + +**Description**: Health check endpoint for monitoring and Kubernetes probes. + +**Request:** +```bash +curl http://localhost:5000/health +``` + +**Response:** +```json +{ + "status": "healthy", + "timestamp": "2024-01-15T14:30:00.000Z", + "uptime_seconds": 3600 +} +``` + +**Status Code**: `200 OK` + +### Testing Commands + +```bash +# Test main endpoint +curl http://localhost:5000/ + +# Test health endpoint +curl http://localhost:5000/health + +# Pretty-print JSON (requires jq) +curl http://localhost:5000/ | jq + +# Test with custom port +PORT=8080 python app.py +curl http://localhost:8080/ + +# Test error handling (404) +curl http://localhost:5000/nonexistent +``` + +## Testing Evidence + +### Screenshots + +Screenshots demonstrating the working endpoints are located in `docs/screenshots/`: +- `01-main-endpoint.png` - Main endpoint showing complete JSON response +- `02-health-check.png` - Health check endpoint response +- `03-formatted-output.png` - Pretty-printed JSON output using jq + +### Terminal Output + +```bash +$ python app.py +2026-01-07 14:30:00,123 - __main__ - INFO - Application starting... +2026-01-07 14:30:00,124 - __main__ - INFO - Starting server on 0.0.0.0:5000 + * Running on http://0.0.0.0:5000 +Press CTRL+C to quit + +$ curl http://localhost:5000/ | jq +{ + "endpoints": [ + { + "description": "Service information", + "method": "GET", + "path": "/" + }, + { + "description": "Health check", + "method": "GET", + "path": "/health" + } + ], + "request": { + "client_ip": "127.0.0.1", + "method": "GET", + "path": "/", + "user_agent": "curl/8.5.0" + }, + "runtime": { + "current_time": "2026-01-23T18:56:22.713364.000Z", + "timezone": "UTC", + "uptime_human": "0 hours, 0 minutes", + "uptime_seconds": 45 + }, + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 12, + "hostname": "j0cos-lenovo", + "platform": "Linux", + "platform_version": "Linux-6.8.0-58-generic-x86_64-with-glibc2.39", + "python_version": "3.12.3" + } +} + + + +$ curl http://localhost:5000/health +{"status":"healthy","timestamp":"2026-01-07T14:30:00.000Z","uptime_seconds":123} +``` + +## Challenges & Solutions + +### Uptime Calculation + +**Problem**: Calculating human-readable uptime format (hours and minutes) from seconds. + +**Solution**: Implemented a function that converts total seconds into hours and minutes, with proper pluralization: +```python +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} hour{'s' if hours != 1 else ''}, {minutes} minute{'s' if minutes != 1 else ''}" + } +``` + +### Client IP Detection + +**Problem**: Getting the correct client IP, especially when behind proxies. + +**Solution**: Implemented fallback logic to check both `request.remote_addr` and `X-Forwarded-For` header: +```python +'client_ip': request.remote_addr or request.environ.get('HTTP_X_FORWARDED_FOR', 'unknown') +``` + +## GitHub Community + +### Why Starring Repositories Matters + +Starring repositories in open source serves multiple important purposes. First, it acts as a bookmarking mechanism, allowing developers to save interesting projects for future reference. More importantly, stars provide valuable feedback to maintainers, showing appreciation for their work and encouraging continued development. High star counts also signal project quality and popularity to the community, helping other developers discover reliable tools and libraries. In a professional context, the repositories you star reflect your interests and awareness of industry best practices, which can be valuable for networking and career growth. + +### How Following Developers Helps + +Following developers on GitHub creates opportunities for learning and professional growth. By following professors, TAs, and classmates, you gain insights into their coding practices, project approaches, and problem-solving techniques. This visibility into others' work helps build a supportive learning community where you can discover new tools, techniques, and project ideas. In team projects, following teammates makes it easier to stay updated on their contributions and find collaborators for future work. Beyond the classroom, following experienced developers exposes you to industry trends, best practices, and real-world applications of technologies you're learning, accelerating your professional development. + diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..f9ec4afc39 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,531 @@ +# Lab 2 Submission: Docker Containerization (Python App) + +## 1. Docker Best Practices Applied + +### 1.1 Use a Specific Base Image Version + +**Practice:** Pin the base image to a specific, slimmed-down Python version. + +**Implementation (Dockerfile):** + +```Dockerfile +FROM python:3.13-slim +``` + +**Why it matters:** +- Ensures reproducible builds (same Python version everywhere). +- `slim` variant removes unnecessary tools, reducing image size and attack surface. +- Easier to reason about compatibility and security updates. + +--- + +### 1.2 Non-Root User + +**Practice:** Do not run the application as `root` inside the container. + +**Implementation:** + +```Dockerfile +RUN addgroup --system app && adduser --system --ingroup app app +USER app +``` + +**Why it matters:** +- Limits the blast radius if the application is compromised. +- Follows the principle of least privilege. +- Many security scanners and platforms now *require* non-root containers. + +--- + +### 1.3 Layer Caching & Dependency Installation + +**Practice:** Install dependencies in a separate layer before copying application code. + +**Implementation:** + +```Dockerfile +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . +``` + +**Why it matters:** +- Docker caches layers. Dependencies change less frequently than source code. +- When only `app.py` changes, Docker reuses the dependency layer and rebuilds much faster. +- `--no-cache-dir` avoids storing wheel caches inside the image, reducing size. + +--- + +### 1.4 Minimize What You Copy + +**Practice:** Only copy the files needed at runtime. + +**Implementation:** + +```Dockerfile +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . +``` + +**Why it matters:** +- Keeps the image smaller by excluding tests, docs, virtualenvs, and git metadata. +- Reduces the attack surface (less code and tools inside the container). +- Faster image distribution and startup times. + +This is reinforced by `.dockerignore`, which excludes unnecessary files from the build context. + +--- + +### 1.5 .dockerignore + +**Practice:** Use `.dockerignore` to avoid sending unnecessary files to the Docker daemon. + +**Implementation (`app_python/.dockerignore`):** + +```dockerignore +__pycache__/ +*.py[cod] +*.pyo +*.pyd + +venv/ +.venv/ + +.git +.gitignore + +.vscode/ +.idea/ + +tests/ +docs/ + +*.log +*.md +``` + +**Why it matters:** +- Smaller build context → faster uploads to the Docker daemon → faster builds. +- Avoids accidentally copying secrets, virtual environments, and dev tooling into the image. +- Mirrors many patterns from `.gitignore`, following common DevOps practice. + +--- + +### 1.6 Environment Configuration & Logging + +**Practice:** Configure runtime via environment variables and ensure unbuffered logs. + +**Implementation:** + +```Dockerfile +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +ENV HOST=0.0.0.0 \ + PORT=5000 +``` + +**Why it matters:** +- `PYTHONUNBUFFERED=1` ensures logs appear immediately in container logs (important for monitoring). +- `PYTHONDONTWRITEBYTECODE=1` avoids `.pyc` files and reduces filesystem noise. +- Environment variables make the same image reusable across environments (dev/stage/prod). + +--- + +## 2. Image Information & Decisions + +### 2.1 Base Image Choice + +**Chosen image:** `python:3.13-slim` + +**Justification:** +- Matches the course requirement for Python 3.13. +- `slim` variant is significantly smaller than the full image while still being easy to work with. +- Based on Debian, which has familiar package management and good security support. + +Alternative options would be: +- `python:3.13` (larger, includes build tools we don't need at runtime). +- `python:3.13-alpine` (smaller, but can cause compatibility issues with some wheels and glibc). + +`3.13-slim` is a good balance between size, compatibility, and simplicity. + +--- + +### 2.2 Final Image Size (Example) + +After building the image: + +```bash +docker images | grep devops-info-service +``` + +Example output: + +```bash +j0cos/devops-info-service lab02 a98f3b0fd122 56 seconds ago 122MB +``` + +**Assessment:** +- This is reasonable for a Python + Flask application with `python:3.13-slim` as the base. +- There is still room for optimization (e.g., using `--no-cache-dir`, removing build tools, reducing layers), many of which are already applied. + +--- + +### 2.3 Layer Structure + +High-level layer structure: + +1. **Base image**: `FROM python:3.13-slim` +2. **System configuration**: Create non-root user +3. **Workdir**: `WORKDIR /app` +4. **Dependencies**: `COPY requirements.txt` + `RUN pip install ...` +5. **Application code**: `COPY app.py .` +6. **Runtime config**: `ENV`, `EXPOSE`, `USER`, `CMD` + +**Why this order works well:** +- Dependency installation is separated from application code for better caching. +- User creation happens once and is reused for all subsequent layers. +- Runtime configuration (`ENV`, `EXPOSE`, `CMD`) is unlikely to change often. + +--- + +### 2.4 Optimization Choices + +- Used `python:3.13-slim` instead of `python:3.13`. +- Installed dependencies with `--no-cache-dir`. +- Avoided copying tests, docs, and virtualenvs into the image. +- Configured logging to be unbuffered for better observability. + +Each choice reduces size, speeds up builds, or improves security/observability. + +--- + +## 3. Build & Run Process + +### 3.1 Build Command + +From `DevOps-Core-Course/app_python/`: + +```bash +docker build -t j0cos/devops-info-service:lab02 . +``` + +Output (truncated): + +```bash +DEPRECATED: The legacy builder is deprecated and will be removed in a future release. + Install the buildx component to build images with BuildKit: + https://docs.docker.com/go/buildx/ + +Sending build context to Docker daemon 9.216kB +Step 1/11 : FROM python:3.13-slim +3.13-slim: Pulling from library/python +0c8d55a45c0d: Pull complete +8a3ca8cbd12d: Pull complete +b3639af23419: Pull complete +0da4a108bcf2: Pull complete +Digest: sha256:2b9c9803c6a287cafa0a8c917211dddd23dcd2016f049690ee5219f5d3f1636e +Status: Downloaded newer image for python:3.13-slim + ---> 464f788e6eab +Step 2/11 : ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 + ---> Running in bbff488f9a3b + ---> Removed intermediate container bbff488f9a3b + ---> 927f18f003c3 +Step 3/11 : RUN addgroup --system app && adduser --system --ingroup app app + ---> Running in a14d124dc0b0 + ---> Removed intermediate container a14d124dc0b0 + ---> 43e066430c56 +Step 4/11 : WORKDIR /app + ---> Running in 957da0cf7e7e + ---> Removed intermediate container 957da0cf7e7e + ---> d73b1ecfb9fd +Step 5/11 : COPY requirements.txt . + ---> 3c99e774ec3d +Step 6/11 : RUN pip install --no-cache-dir -r requirements.txt + ---> Running in 5fdefb26aaa0 +Collecting Flask==3.1.0 (from -r requirements.txt (line 2)) + Downloading flask-3.1.0-py3-none-any.whl.metadata (2.7 kB) +Collecting Werkzeug>=3.1 (from Flask==3.1.0->-r requirements.txt (line 2)) + Downloading werkzeug-3.1.5-py3-none-any.whl.metadata (4.0 kB) +Collecting Jinja2>=3.1.2 (from Flask==3.1.0->-r requirements.txt (line 2)) + Downloading jinja2-3.1.6-py3-none-any.whl.metadata (2.9 kB) +Collecting itsdangerous>=2.2 (from Flask==3.1.0->-r requirements.txt (line 2)) + Downloading itsdangerous-2.2.0-py3-none-any.whl.metadata (1.9 kB) +Collecting click>=8.1.3 (from Flask==3.1.0->-r requirements.txt (line 2)) + Downloading click-8.3.1-py3-none-any.whl.metadata (2.6 kB) +Collecting blinker>=1.9 (from Flask==3.1.0->-r requirements.txt (line 2)) + Downloading blinker-1.9.0-py3-none-any.whl.metadata (1.6 kB) +Collecting MarkupSafe>=2.0 (from Jinja2>=3.1.2->Flask==3.1.0->-r requirements.txt (line 2)) + Downloading markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (2.7 kB) +Downloading flask-3.1.0-py3-none-any.whl (102 kB) +Downloading blinker-1.9.0-py3-none-any.whl (8.5 kB) +Downloading click-8.3.1-py3-none-any.whl (108 kB) +Downloading itsdangerous-2.2.0-py3-none-any.whl (16 kB) +Downloading jinja2-3.1.6-py3-none-any.whl (134 kB) +Downloading markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (22 kB) +Downloading werkzeug-3.1.5-py3-none-any.whl (225 kB) +Installing collected packages: MarkupSafe, itsdangerous, click, blinker, Werkzeug, Jinja2, Flask + +Successfully installed Flask-3.1.0 Jinja2-3.1.6 MarkupSafe-3.0.3 Werkzeug-3.1.5 blinker-1.9.0 click-8.3.1 itsdangerous-2.2.0 +WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning. + +[notice] A new release of pip is available: 25.3 -> 26.0 +[notice] To update, run: pip install --upgrade pip + ---> Removed intermediate container 5fdefb26aaa0 + ---> 909712b3944f +Step 7/11 : COPY app.py . + ---> 51ab146314bd +Step 8/11 : EXPOSE 5000 + ---> Running in 44104e9c0f4b + ---> Removed intermediate container 44104e9c0f4b + ---> b5fe80d73d2b +Step 9/11 : USER app + ---> Running in 18b6c35bf31a + ---> Removed intermediate container 18b6c35bf31a + ---> b2e8ac99c380 +Step 10/11 : ENV HOST=0.0.0.0 PORT=5000 + ---> Running in e98fa6e4d248 + ---> Removed intermediate container e98fa6e4d248 + ---> b7de34239e76 +Step 11/11 : CMD ["python", "app.py"] + ---> Running in ccab856df5e2 + ---> Removed intermediate container ccab856df5e2 + ---> a98f3b0fd122 +Successfully built a98f3b0fd122 +Successfully tagged j0cos/devops-info-service:lab02 +``` + +--- + +### 3.2 Run Command + +```bash +docker run \ + -p 5000:5000 \ + -e HOST=0.0.0.0 \ + -e PORT=5000 \ + j0cos/devops-info-service:lab02 +``` + +Logs: + +```bash +2026-02-04 11:16:36,870 - __main__ - INFO - Application starting... +2026-02-04 11:16:36,871 - __main__ - INFO - Starting server on 0.0.0.0:5000 + * Serving Flask app 'app' + * Debug mode: off +2026-02-04 11:16:36,893 - werkzeug - INFO - WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:5000 + * Running on http://172.17.0.2:5000 +2026-02-04 11:16:36,893 - werkzeug - INFO - Press CTRL+C to quit +2026-02-04 11:16:49,994 - __main__ - INFO - Request: GET / from 172.17.0.1 +2026-02-04 11:16:50,019 - werkzeug - INFO - 172.17.0.1 - - [04/Feb/2026 11:16:50] "GET / HTTP/1.1" 200 - +2026-02-04 11:16:50,061 - werkzeug - INFO - 172.17.0.1 - - [04/Feb/2026 11:16:50] "GET /favicon.ico HTTP/1.1" 404 - +2026-02-04 11:16:52,220 - __main__ - INFO - Request: GET / from 172.17.0.1 +2026-02-04 11:16:52,221 - werkzeug - INFO - 172.17.0.1 - - [04/Feb/2026 11:16:52] "GET / HTTP/1.1" 200 - +``` + +--- + +### 3.3 Testing Endpoints + +From the host machine: + +```bash +# Main endpoint +curl http://localhost:5000/ | jq + +# Health endpoint +curl http://localhost:5000/health +``` + + +Main output: + +```bash +{ + "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": "curl/8.5.0" + }, + "runtime": { + "current_time": "2026-02-04T11:21:45.950829.000Z", + "timezone": "UTC", + "uptime_human": "0 hours, 0 minutes", + "uptime_seconds": 17 + }, + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 12, + "hostname": "29d5c4accb0a", + "platform": "Linux", + "platform_version": "Linux-6.8.0-58-generic-x86_64-with-glibc2.41", + "python_version": "3.13.11" + } +} +``` + +Health output: + +```bash +{ + "status": "healthy", + "timestamp": "2026-02-04T11:21:57.097297.000Z", + "uptime_seconds": 28 +} +``` + +--- + +### 3.4 Docker Hub Repository + +After logging in and pushing: + +```bash +docker login +docker push j0cos/devops-info-service:lab02 +``` + +```bash +The push refers to repository [docker.io/j0cos/devops-info-service] +44fb1c8fbe87: Pushed +dea3653b387c: Pushed +6cb2be6a4910: Pushed +a10bb9028af7: Pushed +111dcdd3167b: Pushed +6f3d061c2e62: Mounted from library/python +1a619cfa942c: Mounted from library/python +c07c86e6f1e8: Mounted from library/python +a8ff6f8cbdfd: Mounted from library/python +lab02: digest: sha256:26829ce1b6858f8e0b7509639e9581d53be83e151afe1d8a5b29b90a5a3eb85f size: 2199 +``` + +#### Naming Strategy +I use /devops-info-service as the repository name and add descriptive tags like lab02 (for this lab’s version) and latest (for the most recent stable build). This makes it clear who owns the image, what service it contains, and which version or lab iteration it corresponds to. + +**Docker Hub URL:** https://hub.docker.com/repository/docker/j0cos/devops-info-service/general + + +--- + +## 4. Technical Analysis + +### 4.1 Why the Dockerfile Works This Way + +- The base image provides Python and OS libraries. +- Environment variables configure Python behavior and runtime defaults. +- Dependency installation is separated for caching efficiency. +- Only the application file is copied, minimizing the image contents. +- The non-root user is used for better security. +- `CMD ["python", "app.py"]` starts the Flask app in the same way as local development. + +--- + +### 4.2 Effect of Changing Layer Order + +If we changed the order to copy `app.py` **before** `requirements.txt`: + +```Dockerfile +COPY . . +RUN pip install --no-cache-dir -r requirements.txt +``` + +**Consequences:** +- Any small code change invalidates the cache for the `pip install` layer. +- Builds become much slower because dependencies are reinstalled on every change. +- More data is sent to the Docker daemon because we copy everything by default. + +Keeping dependencies in an earlier, more stable layer dramatically speeds up rebuilds. + +--- + +### 4.3 Security Considerations + +Security practices implemented: +- Running as a non-root user. +- Using a smaller base image (`slim`) to reduce attack surface. +- Excluding development files and secrets via `.dockerignore`. +- Keeping only runtime dependencies inside the image. + +Potential future improvements: +- Add image scanning (e.g., `docker scan` or Snyk). +- Pin dependencies in `requirements.txt` more strictly and update regularly. + +--- + +### 4.4 .dockerignore Impact + +Without `.dockerignore`: +- The entire project directory (including `venv/`, `.git/`, and docs) would be sent to the Docker daemon. +- Build context size could be hundreds of megabytes. +- Builds would be slower and images might accidentally contain secrets or dev tools. + +With `.dockerignore`: +- Build context stays small and focused. +- Image size is smaller and cleaner. +- Fewer surprises in production environments. + +--- + +## 5. Challenges & Solutions + +### Challenge 1: Choosing the Right Base Image + +**Problem:** Deciding between `python:3.13`, `python:3.13-slim`, and `python:3.13-alpine`. + +**Solution:** Chose `python:3.13-slim` as a balance between size and compatibility. Alpine can cause issues with some Python packages, and the full image is unnecessarily large. + +**Lesson:** Base image choice affects size, security, and compatibility. Slim images are a good default for many Python services. + +--- + +### Challenge 2: Designing .dockerignore + +**Problem:** Deciding what to exclude without blindly copying a huge template. + +**Solution:** Started from `.gitignore` patterns and added: +- Virtual environments +- Docs +- Tests +- IDE configuration + +**Lesson:** `.dockerignore` is as important as `.gitignore` for performance and security. + +--- + +## 6. Summary + +In this lab, I: +- Containerized the Flask application using a production-ready Dockerfile. +- Applied Docker best practices (non-root, layer caching, minimal image contents, `.dockerignore`). +- Documented how to build, run, and publish the image to Docker Hub. +- Analyzed the technical and security implications of design choices. + +The result is a reproducible, portable container image that behaves the same way as the local Python application, but is much easier to run in modern containerized environments. + diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..daff4f8b04 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,74 @@ +## 1. Overview + +- **Testing framework:** `pytest` with `pytest-cov` for coverage. Pytest was chosen for its simple, expressive syntax, rich plugin ecosystem, and excellent support for testing Flask apps with a built-in test client. +- **Endpoints covered by tests:** + - `GET /` – happy path, response structure and types, request metadata, and advertised endpoints list. + - `GET /health` – happy path, status, uptime and timestamp format. + - Error paths: + - `GET /does-not-exist` – 404 JSON error handler. + - Forced internal error in `/` – 500 JSON error handler. + - `POST /` – method-not-allowed behaviour. +- **CI workflow triggers:** + - Runs on `push` and `pull_request` to `master` and `lab*` branches. + - Only triggers when files under `app_python/**` or the workflow file itself change. +- **Versioning strategy:** **CalVer (Calendar Versioning)** using `YYYY.MM.DD`, generated at build time inside the CI workflow. CalVer matches a continuous-delivery style for this service, where release frequency is tied to the date rather than explicit breaking-change semantics. + +--- + +## 2. Workflow Evidence + + + + +- ✅ Successful workflow run (GitHub Actions link): + https://github.com/Rash1d1/DevOps-Core-Course/actions/runs/21961633075 + +- ✅ Tests passing locally (terminal output): +```bash + $ cd app_python + $ pytest +================================== test session starts ================================== +platform linux -- Python 3.12.3, pytest-8.3.4, pluggy-1.6.0 +rootdir: /home/j0cos/innopolis/Devops/DevOps-Core-Course/app_python +plugins: cov-6.0.0 +collected 5 items + +tests/test_app.py ..... [100%] + +=================================== 5 passed in 0.20s =================================== +``` +- ✅ Docker image on Docker Hub: + https://hub.docker.com/repository/docker/j0cos/devops-info-service/tags/2026.02.12/sha256-3a83b9cf2b7463c71e5b44fb103d9777704b9c4fb70e0bf8a7b47cb1c4a62149 + +- ✅ Status badge working in README: + See screenshots folder + + + +--- + +## 3. Best Practices Implemented + +- **Practice 1 – Matrix builds:** The `Python CI` workflow tests against multiple Python versions (`3.11` and `3.12`), increasing confidence that the app and dependencies behave consistently across supported runtimes. +- **Practice 2 – Fail-fast with job dependencies:** Docker build/push and Snyk scanning depend on the lint/test job, so no images are published and no security scan is run if tests fail. +- **Practice 3 – Conditional deployment:** Docker images are only built and pushed when the `master` branch is updated, preventing feature branches from publishing release images. +- **Practice 4 – Concurrency control:** Workflows use a concurrency group per ref with `cancel-in-progress: true`, so outdated runs are cancelled when new commits are pushed to the same branch. +- **Caching:** `actions/setup-python`'s pip cache is enabled with `cache: pip` and `cache-dependency-path: app_python/requirements.txt`. The first run installs dependencies from scratch; subsequent runs reuse the cache and should be noticeably faster (often cutting dependency installation time from tens of seconds to just a few seconds). +- **Snyk:** The `security-scan` job uses `snyk/actions/python-3.12@master` against `app_python/requirements.txt` with a `medium` severity threshold, uploading SARIF results to GitHub’s Security tab. After you configure `SNYK_TOKEN`, the job will report any known vulnerable dependencies. + +Pipeline first run: 1m7s +Second and othes runs: less than 30s + +--- + +## 4. Key Decisions + +- **Versioning Strategy:** CalVer (`YYYY.MM.DD`) was chosen because this service behaves like a continuously deployed application. The date-based version makes it easy to see when an image was produced and works well when breaking-change semantics are less critical than recency. +- **Docker Tags:** Each CI run on `master` produces at least two tags for the Python app: + - `devops-info-service:` (e.g., `2026.02.12`) + - `devops-info-service:latest` + Additional tags (such as branch-specific tags) can be added later if needed. +- **Workflow Triggers:** The workflow is limited to changes in `app_python/**` (plus the workflow file) to avoid unnecessary runs when unrelated files are modified. `push` and `pull_request` events on `master` and lab branches ensure both direct commits and PRs are validated. +- **Test Coverage:** Unit tests focus on the externally visible behaviour of the HTTP endpoints (status codes, JSON structure, timestamps, and error handling) rather than internal implementation details. Non-critical glue code (such as the `__main__` block that starts the Flask server) is intentionally not exercised in tests. + +--- 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..b46eee1b6c 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..ce220e359d 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..885875dfe8 Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/app_python/docs/screenshots/04-GH-badges.png b/app_python/docs/screenshots/04-GH-badges.png new file mode 100644 index 0000000000..06dbe65220 Binary files /dev/null and b/app_python/docs/screenshots/04-GH-badges.png differ diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..596a65f302 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,10 @@ +# Web Framework +Flask==3.1.0 + +# Testing +pytest==8.3.4 +pytest-cov==6.0.0 + +# Linting +flake8==7.1.1 + diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..3ffdcb7af3 --- /dev/null +++ b/app_python/tests/__init__.py @@ -0,0 +1 @@ +# Tests directory diff --git a/app_python/tests/test_app.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..439df66f83 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,128 @@ +import json +from datetime import datetime + +import pytest + +from app import app + + +@pytest.fixture() +def client(): + app.testing = True + with app.test_client() as test_client: + yield test_client + + +def assert_iso8601(timestamp: str) -> None: + """Basic check that a string looks like ISO 8601.""" + # Will raise ValueError if format is invalid + datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + + +def test_index_success_response_structure(client): + response = client.get( + "/", + headers={"User-Agent": "pytest-client"}, + environ_overrides={"REMOTE_ADDR": "127.0.0.1"}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert isinstance(data, dict) + + # Top-level keys + for key in ("service", "system", "runtime", "request", "endpoints"): + assert key in data + + # Service info + service = data["service"] + for key in ("name", "version", "description", "framework"): + assert key in service + assert isinstance(service[key], str) + + # System info + system = data["system"] + for key in ( + "hostname", + "platform", + "platform_version", + "architecture", + "cpu_count", + "python_version", + ): + assert key in system + + assert isinstance(system["cpu_count"], int) + + # Runtime info + runtime = data["runtime"] + for key in ("uptime_seconds", "uptime_human", "current_time", "timezone"): + assert key in runtime + + assert isinstance(runtime["uptime_seconds"], int) + assert isinstance(runtime["uptime_human"], str) + assert_iso8601(runtime["current_time"]) + assert runtime["timezone"] == "UTC" + + # Request info + request_info = data["request"] + assert request_info["client_ip"] == "127.0.0.1" + assert request_info["user_agent"] == "pytest-client" + assert request_info["method"] == "GET" + assert request_info["path"] == "/" + + # Endpoints list + endpoints = data["endpoints"] + assert isinstance(endpoints, list) + assert any(ep["path"] == "/" for ep in endpoints) + assert any(ep["path"] == "/health" for ep in endpoints) + + +def test_index_error_method_not_allowed(client): + # POST is not defined and should return 405 + response = client.post("/") + assert response.status_code == 405 + + +def test_health_success_response_structure(client): + response = client.get("/health") + + assert response.status_code == 200 + data = response.get_json() + assert isinstance(data, dict) + + assert data["status"] == "healthy" + assert isinstance(data["uptime_seconds"], int) + assert_iso8601(data["timestamp"]) + + +def test_not_found_error_handler_returns_json(client): + response = client.get("/does-not-exist") + + assert response.status_code == 404 + data = response.get_json() + assert isinstance(data, dict) + assert data["error"] == "Not Found" + assert "message" in data + + +def test_internal_error_handler_returns_json(client, monkeypatch): + # Force an exception inside the index handler to trigger 500 + def boom(): + raise RuntimeError("boom") + + monkeypatch.setattr("app.get_service_info", lambda: boom()) + + # For this test we want the Flask error handler to run, + # not to propagate the exception to pytest. + from app import app as flask_app # local import to avoid circular issues + + flask_app.testing = False + flask_app.config["PROPAGATE_EXCEPTIONS"] = False + + response = client.get("/") + assert response.status_code == 500 + + data = json.loads(response.data.decode()) + assert data["error"] == "Internal Server Error" + assert "message" in data