diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..037a4ee4d5 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore + +*.pyc +*.pyo +venv/ +.venv/ + +.env +*.pem +secrets/ + +*.md +docs/ + +tests/ diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..e8938ed23d --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,12 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..891c52aaef --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.14.3-slim + +WORKDIR /app + +RUN apt-get update && apt-get upgrade -y && \ + apt-get clean && rm -rf /var/lib/apt/lists/* && \ + pip install --no-cache-dir --upgrade pip + +RUN useradd --create-home appuser && \ + chown -R appuser:appuser /app + +COPY --chown=appuser:appuser requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY --chown=appuser:appuser app.py . + +USER appuser + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s \ + CMD python -c "import urllib.request, json; urllib.request.urlopen('http://localhost:5000/health', timeout=5)" || exit 1 + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..18a83952c0 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,65 @@ +## Overview + +This service provides realtime application, system, and networking data relevant for training in DevOps practices. The data includes app specifics, OS specifics, runtime stats, request data, and available endpoints listing. + +## Prerequisites + +Python `3.12.x+`. + +## Installation + +```py +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +## Running the Application + +Run with default parameters (`HOST=0.0.0.0`, `PORT=5000`): +```bash +python app.py +``` + +Or with custom config: + +```bash +HOST= PORT= DEBUG=True python app.py +``` + +## API Endpoints + +- GET / - Service and system information +- GET /health - Health check + +**Configuration:** + +| Environment Variable | Effect | Default Value | +| -------------------- | --------------------------------------------------------------------------------------- | ------------- | +| HOST | Specifies the host for uvicorn | `0.0.0.0` | +| PORT | Specified the launch port for uvicorn | `5000` | +| DEBUG | Specifies whether to include debug information into responses (currently has no effect) | `False` | + + +## Docker + +This section provides command patterns for using the application in a containerized manner. + + +To build the image locally, execute this command with substitued values: + +```bash +docker build -t :.. +``` + +To run a container, execute this command with substitued values: + +```bash +docker run -d -p :5000 :.. +``` + +To pull the specific version of the image from DockerHub, execute this command with substitued values: + +```bash +docker pull controlw/devops-info-service:.. +``` diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..646c2cc5f6 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,108 @@ +import logging +import os + +import sys + +from app_stats import AppStats +from datetime import datetime, timezone +from pythonjsonlogger import jsonlogger +from typing import Any + +import uvicorn +from fastapi import FastAPI, Request, Response +from fastapi.responses import JSONResponse + +app = FastAPI() +app_logger = logging.getLogger("app") + +if not app_logger.handlers: + handler = logging.StreamHandler(sys.stdout) + formatter = jsonlogger.JsonFormatter(reserved_attrs=[], timestamp=True) + handler.setFormatter(formatter) + app_logger.addHandler(handler) + +request_logger = logging.getLogger("app.request") +error_logger = logging.getLogger("app.error") + +request_logger.setLevel(level=logging.INFO) +error_logger.setLevel(level=logging.ERROR) + + +app_stats = AppStats(name="devops-info-service", + description="DevOps course info service", + major_version=1, + minor_version=0, + patch_version=0) + +@app.get("/", description="Service information") +async def root(request: Request): + request_info = { + "client_ip": request.client.host, + "user_agent": request.headers.get('user-agent'), + "method": request.method, + "path": request.url.path + } + + endpoints_info = list() + for _, key in enumerate(endpoint_paths): + path = key + method = next(iter(endpoint_paths[key])) + description = endpoint_paths[key][method]['description'] + endpoints_info.append({"path": path, "method": method.upper(), "description": description}) + + return { + "service": app_stats.provide_service_info(), + "system": app_stats.provide_system_info(), + "runtime": app_stats.provide_runtime_info(), + "request": request_info, + "endpoints": endpoints_info + } + +@app.get("/health", description="Health check") +async def check_health(): + return { + 'status': 'healthy', + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'uptime_seconds': int(app_stats.get_uptime()) + } + +@app.exception_handler(Exception) +async def handle_general_exception(request: Request, exception: Exception): + error_logger.error( + "Unhandled exception occured in a request", + extra={ + "path": request.url.path, + "method": request.method + }, + exc_info=True) + + return JSONResponse( + status_code=500, + content={"error": "Internal Server Error"} + ) + +@app.middleware("http") +async def log_request(request: Request, call_next): + call_time = datetime.now(timezone.utc) + response: Response = await call_next(request) + execution_time = datetime.now(timezone.utc) - call_time + request_logger.info( + "New request was processed", + extra={ + "path": request.url.path, + "method": request.method, + "execution_time": execution_time, + "status_code": response.status_code + }) + return response + + +endpoint_paths: dict[str, Any] = app.openapi()['paths'] + +if __name__ == "__main__": + HOST = os.getenv('HOST', '0.0.0.0') + PORT = int(os.getenv('PORT', 5000)) + DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' + logging.getLogger("uvicorn").disabled = True + logging.getLogger("uvicorn.access").disabled = True + uvicorn.run(app, host=HOST, port=PORT, log_config=None) diff --git a/app_python/app_stats.py b/app_python/app_stats.py new file mode 100644 index 0000000000..94667a3386 --- /dev/null +++ b/app_python/app_stats.py @@ -0,0 +1,67 @@ +import platform +import socket + +from datetime import datetime, timezone +from typing import Dict + + +class AppStats: + def __init__(self, name: str, description: str, major_version: int, minor_version: int, patch_version: int): + if len(name) == 0 or name is None: + raise ValueError("The service name must not be empty!") + + if len(description) == 0 or description is None: + raise ValueError("The service description must not be empty!") + + if major_version < 0 or minor_version < 0 or patch_version < 0: + raise ValueError("The service version must be non negative!") + + self.start_time = datetime.now(timezone.utc) + self.name = name + self.description = description + self.version = f"{major_version}.{minor_version}.{patch_version}" + self.framework = "FastAPI" + + def provide_service_info(self) -> Dict[str, Any]: + return { + "name": self.name, + "version": self.version, + "description": self.description, + "framework": self.framework + } + + def provide_system_info(self) -> Dict[str, Any]: + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.version(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version() + } + + def get_uptime(self) -> float: + delta = datetime.now(timezone.utc) - self.start_time + return delta.total_seconds() + + def provide_runtime_info(self) -> Dict[str, str]: + uptime_seconds = int(self.get_uptime()) + uptime_minutes = (uptime_seconds // 60) % 60 + uptime_hours = uptime_seconds // 3600 + + if uptime_hours > 1 or uptime_hours == 0: + hours_adapted_wording = "hours" + else: + hours_adapted_wording = "hour" + + if uptime_minutes > 1 or uptime_minutes == 0: + minutes_adapted_wording = "minutes" + else: + minutes_adapted_wording = "minute" + + return { + "uptime_seconds": uptime_seconds, + "uptime_human": f"{uptime_hours} {hours_adapted_wording}, {uptime_minutes} {minutes_adapted_wording}", + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC" + } diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..f53919880d --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,166 @@ +# Lab 1 Report + +## Framework Selection + +| Framework | Characteristics | +| --------- | --------------------------------- | +| Flask | Lightweight, easy to learn | +| FastAPI | Modern, async, auto-documentation | +| Django | Full-featured, includes ORM | + +**FastAPI** was chosen for its fitness for modern best practices and auto-documentation, making it suitable for maitaining a production-ready service continuously throughout various changes. + + +## Best Practices Applied + +### Clean code organization + +> **Why is it important?** + +It keeps the app maintainable in team environments during the entire app lifecycle. + +```python +class AppStats: + def provide_service_info(self) -> Dict[str, Any]... + + def provide_system_info(self) -> Dict[str, Any]... + + def get_uptime(self) -> float... + + def provide_runtime_info(self) -> Dict[str, str]... + +async def check_health()... + +async def handle_general_exception(request: Request, exception: Exception)... + +async def log_request(request: Request, call_next)... +``` + +All the functions are clear, named after verbs, and grouped logically. Code is organized according to PEP8. + +### Universal Error Handling + +> **Why is it important?** +> Protects the app from crashing, conceals implementation details + +```python +@app.exception_handler(Exception) +async def handle_general_exception(request: Request, exception: Exception): + error_logger.error( + "Unhandled exception occured in a request", + extra={ + "path": request.url.path, + "method": request.method + }, + exc_info=True) + + return JSONResponse( + status_code=500, + content={"error": "Internal Server Error"} + ) +``` + +This setup ensures that any unhandled exceptions are explicitly logged, yet the implementation details are concealed from the clients to eliminate leaks. + +### Logging + +> **Why is it important?** +> Enables traceability, debugging, and compliance. + +```python +@app.middleware("http") +async def log_request(request: Request, call_next): + call_time = datetime.now(timezone.utc) + response: Response = await call_next(request) + execution_time = datetime.now(timezone.utc) - call_time + request_logger.info( + "New request was processed", + extra={ + "path": request.url.path, + "method": request.method, + "execution_time": execution_time, + "status_code": response.status_code + }) + return response +``` + +This setup ensures that every request and relevant metadata are always logged. + +## API Documentation + +### Request/Response Examples + +**Example request**: `curl -X 'GET' 'http://127.0.0.1:5000/' -H 'accept: application/json'` + +**Response**: +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": { + "hostname": "Master-mind", + "platform": "Windows", + "platform_version": "10.0.26100", + "architecture": "AMD64", + "cpu_count": 16, + "python_version": "3.12.10" + }, + "runtime": { + "uptime_seconds": 17, + "uptime_human": "0 hours, 0 minutes", + "current_time": "2026-01-27T15:54:17.408310+00:00", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + "method": "GET", + "path": "/" + }, + "endpoints": [ + { + "path": "/", + "method": "GET", + "description": "Service information" + }, + { + "path": "/health", + "method": "GET", + "description": "Health check" + } + ] +} +``` + +**Example request**: `curl -X 'GET' 'http://127.0.0.1:5000/health' -H 'accept: application/json'` + +```json +{ + "status": "healthy", + "timestamp": "2026-01-27T15:57:59.234197+00:00", + "uptime_seconds": 239 +} +``` + +### Challenges & Solutions + +**Problems encountered**: + +- Universal logging required request middleware and proper separation of loggers in accordance to FastAPI best practices + - Solved: created a custom middleware, injected logs into request logger for common logs and into error logger for exception logs +- Universal error handling required its own middleware alongside detail concealment from client for security vs. detail exposure in logs for maintainability + - Solved: all exceptions are handled in a custom middleware that first logs the details and then returns a plain response + +## GitHub Community + +> Why does starring repositories matter in open source? + +Starring repositories clearly indicates the projects' popularity and community trust, making it easier to discover high-value projects. + +> How does following developers helps in team projects and professional growth? + +Following developers helps to stay updated on colleagues' work, which facilitates learning across community. diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..572a1a2ae9 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,116 @@ +## Implemented practices + +1. Using slim image + +- **Why does it matter?** + - Choosing the slim version minimizes the image size while ensuring that minimally required binaries are present + - Choosing the slim version minimizes attack surface on the future container + +The latest stable release is pinned for reproducibility: +```Dockerfile +FROM python:3.14.3-slim +``` + +2. Upgrading all installed packages + +- **Why does it matter?** + - Upgrading packages to the latest version minimizes security vulnerabilities + +The first step of the dockerfile is to upgrade all the packages: +```Dockerfile +RUN apt-get update && apt-get upgrade -y +``` + +3. Clean-up of installation leftover files + +- **Why does it matter?** + - Minimizes the image size + - Minimizes the attack surface + +Clean-up is performed right after updating the packages: +```Dockerfile +apt-get clean && rm -rf /var/lib/apt/lists/* +``` + +4. Non-root user + +- **Why does it matter?** + - Minimizes the damage from successful attacks + - Limits privelege escalation + +This command creates the new user and the corresponding home directory for binaries that rely on its presence: +```Dockerfile +RUN useradd --create-home appuser && \ + chown -R appuser:appuser /app +... +USER appuser +``` + +5. Proper layer ordering + +- **Why does it matter?** + - Minimizes build time by resuing the unchanged layers + - Minimizes the network delivery time by locally caching shared layers between images + +6. Built-in healthcheck procedure + +- **Why does it matter?** + - Provides near real-time insight into a container's health + - Helps in debugging and identifying issues early in the lifecycle + +This command queries the `/health` endpoint every 30 seconds after a startup period of 30 seconds: +```Dockerfile +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s \ + CMD python -c "import urllib.request, json; urllib.request.urlopen('http://localhost:5000/health', timeout=5)" || exit 1 +``` + + +## Image Information & Decisions + +The `python:3.14.3-slim` image was chosen as the base image. The primary reasons are: +1. It is the latest stable python version +2. This is the slim (minimized) version + +Final image size is 174MB. + +**Layes Structure Optimization**: +1. Base image, never changes unless replaced explicitly +2. Upgrade and cleanup of all the packages +3. Create a new non-root user +4. Install all required dependencies +5. Copy the source code + +The layers are written in the order of ascending probability of change. + + +## Build & Run Process + +Docker image building process: +![Image Build Screenshot](screenshots/image_build.png) + +Running and testing the container: +![Running and Building Screenshot](screenshots/run_and_test.png) + +Pushing the image to DockerHub: +![DockerHub push screenshot](screenshots/dockerhub_push_screenshot.png) + +The image can be found on [DockerHub](https://hub.docker.com/repository/docker/controlw/devops-info-service/general). + +- **Tagging strategy**: Use three-digit versions to clearly pin the change history. + + +## Technical Analysis + +- Why does your Dockerfile work the way it does? + - Because it is written in this specific manner +- What would happen if you changed the layer order? + - Some changes in ordering can break the build process, but the majority of potential changes would prevent efficient caching and thus inflate the build time +- What security considerations did you implement? + - Minimal base image, non-root user +- How does .dockerignore improve your build? + - It significantly cuts the build context that is sent to Docker Daemon, thus making the build faster + + +## Challenges & Solutions + +The key issue I ecountered was the unintuitive behaviour of the healthcheck instruction set. After observing the healthy and unhealthy states, I realized that the healthcheck failed due to lack of curl installation. Then, I rewritten the instruction to rely on base Python library instead. diff --git a/app_python/docs/screenshots/dockerhub_push_screenshot.png b/app_python/docs/screenshots/dockerhub_push_screenshot.png new file mode 100644 index 0000000000..93a535949e Binary files /dev/null and b/app_python/docs/screenshots/dockerhub_push_screenshot.png differ diff --git a/app_python/docs/screenshots/formatted.png b/app_python/docs/screenshots/formatted.png new file mode 100644 index 0000000000..0af07238bb Binary files /dev/null and b/app_python/docs/screenshots/formatted.png differ diff --git a/app_python/docs/screenshots/health_endpoint.png b/app_python/docs/screenshots/health_endpoint.png new file mode 100644 index 0000000000..223be4964a Binary files /dev/null and b/app_python/docs/screenshots/health_endpoint.png differ diff --git a/app_python/docs/screenshots/image_build.png b/app_python/docs/screenshots/image_build.png new file mode 100644 index 0000000000..4bdf629660 Binary files /dev/null and b/app_python/docs/screenshots/image_build.png differ diff --git a/app_python/docs/screenshots/root_endpoint.png b/app_python/docs/screenshots/root_endpoint.png new file mode 100644 index 0000000000..1be85b3233 Binary files /dev/null and b/app_python/docs/screenshots/root_endpoint.png differ diff --git a/app_python/docs/screenshots/run_and_test.png b/app_python/docs/screenshots/run_and_test.png new file mode 100644 index 0000000000..61a080a9a5 Binary files /dev/null and b/app_python/docs/screenshots/run_and_test.png differ diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..a4039885c6 --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt + +pytest==9.0.2 diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..90bfe62ece --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.120.3 +uvicorn[standard]==0.32.0 +python-json-logger==4.0.0 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_stats.py b/app_python/tests/test_app_stats.py new file mode 100644 index 0000000000..8d6fcc512d --- /dev/null +++ b/app_python/tests/test_app_stats.py @@ -0,0 +1,52 @@ +import pytest +import random + +from app_stats import AppStats + + +def test_init_integrity_break(): + with pytest.raises(ValueError) as exception_info: + app_stats = AppStats("", "description", 1, 1, 1) + + assert "name" in str(exception_info) + + with pytest.raises(ValueError) as exception_info: + app_stats = AppStats("name", "", 1, 1, 1) + + assert "description" in str(exception_info) + + with pytest.raises(ValueError) as exception_info: + app_stats = AppStats("name", "description", -1, 1, 1) + + assert "version" in str(exception_info) + + with pytest.raises(ValueError) as exception_info: + app_stats = AppStats("name", "description", 1, -1, 1) + + assert "version" in str(exception_info) + + with pytest.raises(ValueError) as exception_info: + app_stats = AppStats("name", "description", 1, 1, -1) + + assert "version" in str(exception_info) + + name = "test name" + description = "test description" + + reproducibility_seed = random.random() + random.seed(reproducibility_seed) + print("Seed for reproducibility: ", reproducibility_seed) + + for i in range(100): + major_version = random.random(0, 999) + minor_version = random.random(0, 999) + patch_version = random.random(0, 999) + version + + app_stats = AppStats(name, description, major_version, minor_version, patch_version) + assert (app_stats.name, app_stats.description) + + +def test_service_info(): + app_stats = AppStats("name", "description", 1, 0, 0) +