diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..8f9b7095ff --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,87 @@ +name: Python CI (app_python) + +on: + push: + branches: ["master"] + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + pull_request: + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + +concurrency: + group: python-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-and-lint: + runs-on: ubuntu-latest + defaults: + run: + working-directory: app_python + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + cache-dependency-path: | + app_python/requirements.txt + app_python/requirements-dev.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-dev.txt + + - name: Lint (ruff) + run: | + ruff check . + + - name: Run tests (pytest) + run: | + pytest -q + + - name: Install Snyk CLI + run: npm install -g snyk + + - name: Snyk scan (dependencies) + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: snyk test --file=requirements.txt --severity-threshold=high || true + + + docker-build-and-push: + runs-on: ubuntu-latest + needs: test-and-lint + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Generate CalVer version + run: | + echo "VERSION=$(date -u +%Y.%m.%d)-${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./app_python + file: ./app_python/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:${{ env.VERSION }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:latest diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..5db5bf582e --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,13 @@ +pycache/ +*.py[cod] +venv/ +.env +*.log + +.vscode/ +.idea/ +.git +.gitignore + +docs/ +tests/ diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..3ca35248a3 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,14 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +.env +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..c4f33cf3af --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN adduser --disabled-password --gecos "" appuser + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +USER appuser + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..18ae98e060 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,80 @@ +# DevOps Info Service (FastAPI) + +[![Python CI (app_python)](https://github.com/fayz131/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/fayzullin/DevOps-Core-Course/actions/workflows/python-ci.yml) + + +## Overview +DevOps Info Service is a web application that provides information about the running service and the system it is running on. The application is designed as a foundation for future DevOps labs, including containerization, CI/CD, and monitoring. + +## Prerequisites +- Python 3.11 or newer +- pip +- Python virtual environment (venv) + +## Installation +Navigate to the application directory: + +cd app_python + +Create and activate a virtual environment: + +python3 -m venv venv +source venv/bin/activate + +Install dependencies: + +pip install -r requirements.txt + +## Running the Application +Start the application: + +python app.py + +Run with custom configuration: + +HOST=127.0.0.1 PORT=8080 python app.py + +## API Endpoints + +GET / +Returns service, system, runtime, and request information. + +GET /health +Returns application health status and uptime. + +## Configuration + +Environment variables: + +HOST — server host (default: 0.0.0.0) +PORT — server port (default: 5000) + +## Docker + +### Build image + +```bash +docker build -t devops-info-service:lab2 . +``` + +Run container +```bash +docker run --rm -p 5000:5000 devops-info-service:lab2 +``` + +From Docker Hub +```bash +docker pull fayzullin/devops-info-service:lab2 +docker run --rm -p 5000:5000 fayzullin/devops-info-service:lab2 +``` + +## Testing + +Install dev dependencies and run tests: + +```bash +pip install -r requirements.txt -r requirements-dev.txt +pytest +``` + + diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..29cb4e95d9 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,122 @@ +""" +DevOps Info Service +FastAPI web application providing system and runtime information. +""" + +import os +import socket +import platform +import logging +from datetime import datetime, timezone + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +import uvicorn + + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 5000)) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +START_TIME = datetime.now(timezone.utc) +app = FastAPI(title="DevOps Info Service") + +logger.info("Application initialized") + + +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, f"{hours} hours, {minutes} minutes" + + +def get_system_info(): + """Collect system information.""" + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.release(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version(), + } + + +@app.get("/") +async def index(request: Request): + """Main endpoint returning service and system information.""" + logger.info("Handling request to '/'") + + uptime_seconds, uptime_human = get_uptime() + + return { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI", + }, + "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.client.host, + "user_agent": request.headers.get("user-agent"), + "method": request.method, + "path": request.url.path, + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + ], + } + + +@app.get("/health") +async def health(): + """Health check endpoint for monitoring.""" + logger.info("Health check requested") + + uptime_seconds, _ = get_uptime() + return { + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": uptime_seconds, + } + + +@app.exception_handler(404) +async def not_found(request: Request, exc): + """Handle 404 errors.""" + return JSONResponse( + status_code=404, + content={"error": "Not Found", "message": "Endpoint does not exist"}, + ) + + +@app.exception_handler(500) +async def internal_error(request: Request, exc): + """Handle unexpected server errors.""" + logger.error(f"Internal server error: {exc}") + return JSONResponse( + status_code=500, + content={"error": "Internal Server Error", "message": "An unexpected error occurred"}, + ) + +if __name__ == "__main__": + logger.info(f"Starting server on {HOST}:{PORT}") + uvicorn.run("app:app", host=HOST, port=PORT) + diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..4157c81f1f --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,80 @@ +# LAB01 — DevOps Info Service + +## Framework Selection +For this lab, FastAPI was chosen as the web framework due to its modern design, +high performance, and built-in support for OpenAPI documentation. This makes it a suitable choice for building production-ready services and for future DevOps labs. + +| Framework | Advantages | Disadvantages | +|----------|------------|---------------| +| Flask | Simple and lightweight | No built-in API docs | +| FastAPI | Async, automatic docs, fast | Slight learning curve | +| Django | Full-featured framework | Overkill for small services | + +--- + +## Best Practices Applied +The following best practices were applied during development: + +- Clear and simple project structure +- Environment-based configuration using `HOST` and `PORT` +- Separation of logic into helper functions +- Use of UTC timezone for all runtime timestamps +- Dependency management using `requirements.txt` +- Virtual environment usage +- Handling of invalid endpoints using a custom 404 handler + +These practices improve readability, portability, and reliability of the application. + +--- + +## API Documentation + +### Main Endpoint — `GET /` +Returns detailed information about the service, system, runtime state, request metadata, and available endpoints. + +Example request: +```bash +curl http://localhost:5000/ +``` +The response includes: +- Service metadata (name, version, framework) +- System information (hostname, OS, CPU, Python version) +- Runtime information (uptime, current UTC time) +- Request details (client IP, user agent, HTTP method) +- List of available endpoints + +--- + +### Health Check — `GET /health` + +Returns the current health status of the application and uptime in seconds. + +Example request: +```bash +curl http://localhost:5000/health +``` +--- + +## Testing Evidence + +To confirm correct application behavior, the following screenshots were taken: + +- `01-main-endpoint.png` — response from the main endpoint (`GET /`) +- `02-health-check.png` — response from the health check endpoint (`GET /health`) +- `03-formatted-output.png` — formatted JSON output in the terminal + +All screenshots are located in the `docs/screenshots` directory. + +--- + +## Challenges & Solutions + +One of the challenges encountered was handling requests to non-existent endpoints. +This was solved by implementing a custom 404 error handler that returns a clear JSON response instead of a default HTML error page. + +--- + +## GitHub Community + +Starring repositories on GitHub helps support open-source maintainers and makes it easier to keep track of useful projects. +Following developers allows learning from their work, staying updated on new technologies, and building professional connections within the developer community. diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..37021d5f61 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,86 @@ +# Lab 2 — Docker Containerization + +## Docker Best Practices Applied + +- **Specific base image version** + Used `python:3.12-slim` as a lightweight official Python image. Using a specific version makes builds reproducible and avoids unexpected changes when the latest tag is updated. + +- **Layer caching with requirements.txt** + `requirements.txt` is copied and dependencies are installed before copying the application code. This allows Docker to reuse the dependency layer when only the code changes, speeding up rebuilds. + +- **Non-root user** + A dedicated non-root user `appuser` is created and the application is started under this user. Running containers as non-root reduces the impact of potential security vulnerabilities. + +- **Minimal file copy** + Only the files required at runtime are copied into the image (`requirements.txt` and `app.py`). Test files, documentation, and development artifacts are excluded via `.dockerignore`. This reduces image size and attack surface. + +- **Environment variables for Python** + `PYTHONDONTWRITEBYTECODE` and `PYTHONUNBUFFERED` are set to prevent `.pyc` creation and to ensure unbuffered output, which is useful for logging in containers. + +## Image Information & Decisions + +- **Base image:** `python:3.12-slim` + Chosen as a good balance between size and compatibility. The slim image is smaller than the full Python image but still based on Debian. + +- **Layer structure:** + 1. Pull base image + 2. Set environment variables + 3. Set working directory + 4. Create non-root user + 5. Copy `requirements.txt` and install dependencies + 6. Copy application code + 7. Switch to non-root user + 8. Set default command + +- **Optimization choices:** + - `--no-cache-dir` for pip + - `.dockerignore` excludes `venv`, `.git`, `docs`, `tests`, etc. + - Running as non-root user + +## Build & Run Process + +### Build + +```bash +docker build -t devops-info-service:lab2 . +``` + +### Run locally + +```bash +docker run --rm -p 5000:5000 devops-info-service:lab2 +``` + +### Test endpoints + +```bash +curl http://localhost:5000/ +curl http://localhost:5000/health +``` + +### Docker Hub repository + +Image is available at: +https://hub.docker.com/r/fayzullin/devops-info-service + + +Tag used: +```bash +fayzullin/devops-info-service:lab2 +``` + +### Technical Analysis + +The Dockerfile installs dependencies before copying the application code. If the order was reversed, any code change would force dependencies to be reinstalled on every build. Running as a non-root user improves security, and .dockerignore reduces the build context size, making builds faster and images smaller. Additionally, running the container as a non-root user reduces the potential impact of container escape vulnerabilities and follows Docker security best practices. + + +### Challenges & Solutions + +**Challenge:** Understanding how layer caching influences build speed. +**Solution:** Reordered layers so that dependency installation is separated from application code. + +**Challenge:** Running the app as a non-root user. +**Solution:** Created a dedicated appuser user and switched to it using the USER directive. + +**Challenge:** Reducing image size. +**Solution:** Used python:3.12-slim, disabled pip cache, and excluded unnecessary files via .dockerignore. diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..9aebd42ef7 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,83 @@ +# Lab 3 — Continuous Integration (CI/CD) + +## Overview + +This lab introduces automated testing and CI/CD using GitHub Actions for the FastAPI DevOps Info Service. + +The pipeline performs: +- Linting (ruff) +- Unit testing (pytest) +- Security scanning (Snyk) +- Docker image build and push to Docker Hub + +## Testing Framework + +**Framework used:** pytest + +Pytest was chosen because: +- Simple and readable assertions +- Great integration with FastAPI +- Industry standard in modern Python projects + +### Tests Implemented + +- `GET /` — validates response structure and required fields +- `GET /health` — validates health check structure +- `404 handler` — validates JSON error response + +### Run tests locally + +```bash +pip install -r requirements.txt -r requirements-dev.txt +pytest +``` + +## CI Workflow + +Workflow file: +.github/workflows/python-ci.yml + +### Trigger Strategy + +Workflow runs on: + +* Pull requests affecting app_python/** +* Push to master affecting app_python/** + +Path filters prevent unnecessary runs in monorepo. + +### Versioning Strategy + +Strategy: Calendar Versioning (CalVer) + +Format: +YYYY.MM.DD- + +Docker tags created: + +* fayzullin/devops-info-service: + +* fayzullin/devops-info-service:latest + +This is suitable for continuously deployed services. + +## CI Best Practices Applied + +Fail fast — Docker build runs only if tests pass. + +Dependency caching — pip cache speeds up builds. + +Path filters — workflow runs only when app_python changes. + +Concurrency control — cancels outdated runs. + +## Security Scanning + +Snyk is integrated to scan dependencies. +Build fails only on high severity vulnerabilities + +## Evidence + +GitHub Actions run: (add link after successful run) + +Docker Hub: https://hub.docker.com/r/fayzullin/devops-info-service 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..690563c723 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..1719f04a17 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..de12345346 Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..34d28434a1 --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,4 @@ +pytest==8.3.3 +httpx==0.27.2 +ruff==0.7.2 + diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..ebc98913e8 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.8 +uvicorn[standard]==0.32.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.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..066aa56152 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,60 @@ +from fastapi.testclient import TestClient + +from app import app + +client = TestClient(app) + + +def test_root_returns_required_structure(): + response = client.get("/", headers={"User-Agent": "pytest"}) + assert response.status_code == 200 + + data = response.json() + + assert "service" in data + assert "system" in data + assert "runtime" in data + assert "request" in data + assert "endpoints" in data + + service = data["service"] + assert service["name"] == "devops-info-service" + assert service["version"] == "1.0.0" + assert service["framework"] == "FastAPI" + + system = data["system"] + for key in ["hostname", "platform", "platform_version", "architecture", "cpu_count", "python_version"]: + assert key in system + + runtime = data["runtime"] + assert isinstance(runtime["uptime_seconds"], int) + assert runtime["uptime_seconds"] >= 0 + assert isinstance(runtime["uptime_human"], str) + assert isinstance(runtime["current_time"], str) + assert runtime["timezone"] == "UTC" + + req = data["request"] + assert req["method"] == "GET" + assert req["path"] == "/" + assert isinstance(req["user_agent"], (str, type(None))) + + +def test_health_endpoint(): + response = client.get("/health") + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "healthy" + assert isinstance(data["timestamp"], str) + assert isinstance(data["uptime_seconds"], int) + assert data["uptime_seconds"] >= 0 + + +def test_404_returns_json(): + response = client.get("/does-not-exist") + assert response.status_code == 404 + + data = response.json() + assert data["error"] == "Not Found" + assert "message" in data +