diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..3269f5d97d --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,86 @@ +name: python-ci + +on: + push: + branches: + - master + paths: + - "lab_solutions/lab1/app_python/**" + - ".github/workflows/python-ci.yml" + pull_request: + paths: + - "lab_solutions/lab1/app_python/**" + - ".github/workflows/python-ci.yml" + +concurrency: + group: python-ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + APP_DIR: lab_solutions/lab1/app_python + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: "pip" + cache-dependency-path: | + lab_solutions/lab1/app_python/requirements.txt + lab_solutions/lab1/app_python/requirements-dev.txt + + - name: Install dependencies + working-directory: ${{ env.APP_DIR }} + run: pip install -r requirements.txt -r requirements-dev.txt + + - name: Lint + working-directory: ${{ env.APP_DIR }} + run: ruff check . + + - name: Run tests with coverage + working-directory: ${{ env.APP_DIR }} + run: pytest --cov=app --cov-report=term --cov-report=xml + + - name: Snyk scan + uses: snyk/actions/python@0.4.0 + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --file=lab_solutions/lab1/app_python/requirements.txt --severity-threshold=high --skip-unresolved + + docker: + needs: test + runs-on: ubuntu-latest + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') + steps: + - name: Checkout + 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 version (CalVer) + run: echo "VERSION=$(date +%Y.%m.%d)" >> $GITHUB_ENV + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: ${{ env.APP_DIR }} + file: ${{ env.APP_DIR }}/Dockerfile + push: true + tags: | + ${{ env.IMAGE_NAME }}:${{ env.VERSION }} + ${{ env.IMAGE_NAME }}:latest diff --git a/lab_solutions/lab1/app_python/.dockerignore b/lab_solutions/lab1/app_python/.dockerignore new file mode 100644 index 0000000000..37cd402203 --- /dev/null +++ b/lab_solutions/lab1/app_python/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info/ +dist/ +build/ + +# Virtual environments +venv/ +.venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Git +.git/ +.gitignore + +# Logs +*.log + +# Tests +tests/ +.pytest_cache/ + +# Documentation +docs/ +README.md diff --git a/lab_solutions/lab1/app_python/.gitignore b/lab_solutions/lab1/app_python/.gitignore new file mode 100644 index 0000000000..4de420a8f7 --- /dev/null +++ b/lab_solutions/lab1/app_python/.gitignore @@ -0,0 +1,12 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store \ No newline at end of file diff --git a/lab_solutions/lab1/app_python/Dockerfile b/lab_solutions/lab1/app_python/Dockerfile new file mode 100644 index 0000000000..c751d50e2b --- /dev/null +++ b/lab_solutions/lab1/app_python/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.13-slim + +# restricted user +RUN useradd --create-home --shell /bin/bash appuser + +WORKDIR /app + +COPY requirements.txt . + +# pip installation requires root +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +# adduser now owns app +RUN chown -R appuser:appuser /app + +USER appuser + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/lab_solutions/lab1/app_python/README.md b/lab_solutions/lab1/app_python/README.md new file mode 100644 index 0000000000..9bbfd3b80e --- /dev/null +++ b/lab_solutions/lab1/app_python/README.md @@ -0,0 +1,66 @@ +# DevOps Info Service (FastAPI) + +[![python-ci](https://github.com///actions/workflows/python-ci.yml/badge.svg)](https://github.com///actions/workflows/python-ci.yml) + +## Overview +A lightweight web service that reports system and runtime information for the DevOps course labs. + +## Prerequisites +- Python 3.11+ +- FastAPI - perfect fit for small WEB application here. It has built-in OpenAPI docs and provides modern async interface. + +## Installation +```bash +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +## Running the Application +```bash +python app.py +# Or with custom config +PORT=8080 python app.py +``` + +## Testing +```bash +pip install -r requirements.txt -r requirements-dev.txt +pytest +``` + +## Linting +```bash +ruff check . +``` + +## API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check + +## Configuration +| Variable | Default | Description | +| --- | --- | --- | +| HOST | 0.0.0.0 | Bind address | +| PORT | 5000 | HTTP port | +| DEBUG | False | Enable auto-reload and debug logging | + +## Docker + +### Building the Image +```bash +docker build -t chupapupa/devops-info-service:latest . +``` + +### Running the Container +```bash +docker run -p 5000:5000 chupapupa/devops-info-service:latest +# With custom port +docker run -p 8080:5000 -e PORT=5000 chupapupa/devops-info-service:latest +``` + +### Pulling from Docker Hub +```bash +docker pull chupapupa/devops-info-service:latest +docker run -p 5000:5000 chupapupa/devops-info-service:latest +``` diff --git a/lab_solutions/lab1/app_python/app.py b/lab_solutions/lab1/app_python/app.py new file mode 100644 index 0000000000..6791c574c0 --- /dev/null +++ b/lab_solutions/lab1/app_python/app.py @@ -0,0 +1,109 @@ +import os +import logging +import platform +import socket +from datetime import datetime, timezone +from fastapi import FastAPI, Request + + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "5000")) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + + +logging.basicConfig( + level=logging.DEBUG if DEBUG else logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) + + +logger = logging.getLogger(__name__) + + +START_TIME = datetime.now(timezone.utc) + + +app = FastAPI(title="DevOps Info Service", version="1.0.0") + + +def get_uptime(): + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return {"seconds": seconds, "human": f"{hours} hours, {minutes} minutes"} + + +def get_system_info(): + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.platform(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count() or 0, + "python_version": platform.python_version(), + } + + +def get_request_info(request: Request): + client_ip = request.client.host if request.client else "unknown" + user_agent = request.headers.get("user-agent", "unknown") + return { + "client_ip": client_ip, + "user_agent": user_agent, + "method": request.method, + "path": request.url.path, + } + + +def get_runtime_info(): + now = datetime.now(timezone.utc) + return { + "uptime_seconds": get_uptime()["seconds"], + "uptime_human": get_uptime()["human"], + "current_time": now.isoformat().replace("+00:00", "Z"), + "timezone": now.tzname() or "UTC", + } + + +@app.get("/") +async def index(request: Request): + logger.info("Request: %s %s", request.method, request.url.path) + return { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI", + }, + "system": get_system_info(), + "runtime": get_runtime_info(), + "request": get_request_info(request), + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + ], + } + + +@app.get("/health") +async def health(): + now = datetime.now(timezone.utc) + return { + "status": "healthy", + "timestamp": now.isoformat().replace("+00:00", "Z"), + "uptime_seconds": get_uptime()["seconds"], + } + + +if __name__ == "__main__": + import uvicorn + + logger.info("Starting application on %s:%s", HOST, PORT) + uvicorn.run( + "app:app", + host=HOST, + port=PORT, + reload=DEBUG, + log_level="debug" if DEBUG else "info", + ) diff --git a/lab_solutions/lab1/app_python/docs/LAB01.md b/lab_solutions/lab1/app_python/docs/LAB01.md new file mode 100644 index 0000000000..1762dfb035 --- /dev/null +++ b/lab_solutions/lab1/app_python/docs/LAB01.md @@ -0,0 +1,11 @@ +# LAB01 - FastAPI Selection + +## Framework Selection +**FastAPI** has been chosen for this lab because it provides automatic documentation, excellent performance, and modern API while staying lightweight for a simple service. + +### Quick Comparison +| Framework | Pros | Cons | +| --- | --- | --- | +| FastAPI | Fast, async-ready, built-in docs, type hints | Slightly more setup than Flask | +| Flask | Minimal, easy to learn | No built-in docs, fewer batteries | +| Django | Full-featured, ORM included | Heavy for a small API | diff --git a/lab_solutions/lab1/app_python/docs/LAB02.md b/lab_solutions/lab1/app_python/docs/LAB02.md new file mode 100644 index 0000000000..2b676a4326 --- /dev/null +++ b/lab_solutions/lab1/app_python/docs/LAB02.md @@ -0,0 +1,160 @@ +# LAB02 - Docker Containerization + +## Docker Best Practices Applied + +### 1. Non-Root User +**Implementation:** +```dockerfile +RUN useradd --create-home --shell /bin/bash appuser +USER appuser +``` + +**Non-Root User motivation:** Running containers as root is a security risk. If an attacker compromises the application, they gain root privileges inside the container, which can be escalated to host-level access in some scenarios. Non-root users limit the damage potential. + +### 2. Specific Base Image Version +**Implementation:** +```dockerfile +FROM python:3.13-slim +``` + +**Why it matters:** Using a specific version (not `latest`) ensures reproducible builds. The `slim` variant reduces image size by excluding unnecessary packages, reducing attack surface and download time. + +### 3. Layer Caching Optimization +**Implementation:** +```dockerfile +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . +``` + +**Why it matters:** Docker caches layers. By copying `requirements.txt` before application code, dependency installation is only re-run when dependencies change. Since code changes more often than dependencies, this speeds up rebuilds significantly. + +### 4. .dockerignore File +**Implementation:** Excluded unnecessary files like `venv/`, `__pycache__/`, `.git/`, `docs/`, `tests/` + +**Why it matters:** Reduces build context size sent to Docker daemon, speeding up builds. Also prevents accidentally copying sensitive files or development artifacts into the image. + +### 5. Minimal File Copying +**Implementation:** Only copied essential files (`requirements.txt` and `app.py`) + +**Why it matters:** Smaller images mean faster deployments, less storage, and reduced attack surface. Each file copied is a potential security or bloat issue. + +### 6. No Cache for pip +**Implementation:** +```dockerfile +RUN pip install --no-cache-dir -r requirements.txt +``` + +**Why it matters:** pip cache is useless in a container (build-time only) and wastes space in the final image. + +--- + +## Image Information & Decisions + +### Base Image: `python:3.13-slim` +**Justification:** +- **Slim variant:** Balances size and functionality - includes what Python apps need, excludes build tools and docs +- **Version 3.13:** Latest stable Python, matches development environment +- **Alternative considered:** `alpine` is smaller but uses `musl` instead of `glibc`, causing compatibility issues with some Python packages + +### Final Image Size +164MB + +### Layer Structure +1. Base image (`python:3.13-slim`) +2. User creation +3. Working directory setup +4. Dependency installation (cached layer) +5. Application code (changes frequently) +6. Permission changes +7. User switch + +### Optimization Choices +- Separated dependency installation from code copy for caching +- Used `--no-cache-dir` to avoid storing pip cache +- Created non-root user before copying files to ensure proper ownership + +--- + +## Build & Run Process + +### Build Output +```bash +# Build command +docker build -t chupapupa/devops-info-service:latest . + +``` + +### Run Output +```bash +# Run command +docker run -p 5000:5000 chupapupa/devops-info-service:latest + +``` + +### Testing Endpoints +```bash + +curl http://localhost:5000/ + + +curl http://localhost:5000/health +``` + +### Docker Hub Repository +URL: `https://hub.docker.com/r/chupapupa/devops-info-service` + +--- + +## Technical Analysis + +### Why Does This Dockerfile Work? + +1. **Layer Order:** Dependencies are installed before code is copied, leveraging Docker's layer caching. If only `app.py` changes, Docker reuses the cached dependency layer. + +2. **User Permissions:** Files are copied as root, then ownership is changed to `appuser`. This ensures the non-root user can read application files while maintaining security. + +3. **Working Directory:** `WORKDIR /app` creates the directory if it doesn't exist and sets it as the context for subsequent commands. + +### What Would Happen If Layer Order Changed? + +If we copied `app.py` before `requirements.txt`: +- Every code change would invalidate the dependency installation layer +- Builds would take much longer (re-installing packages every time) +- No functional difference, just slower workflow + +### Security Considerations + +1. **Non-root user:** Limits privilege escalation potential +2. **Slim base image:** Reduces attack surface (fewer packages = fewer vulnerabilities) +3. **Specific versions:** Prevents supply chain attacks via `latest` tag changes +4. **Minimal file copying:** Reduces risk of exposing sensitive files + +### .dockerignore Benefits + +1. **Build Speed:** Smaller build context = faster uploads to Docker daemon +2. **Security:** Prevents accidentally copying `.git`, `.env`, or credentials +3. **Image Size:** Excludes unnecessary files like `venv/` or `__pycache__/` + +--- + +## Challenges & Solutions + +### Challenge 1: Permission Denied When Running as Non-Root +**Problem:** Initially tried switching to non-root user before copying files, which caused permission errors. + +**Solution:** Copy files as root first, then use `chown` to change ownership, then switch user. Order matters! + +### Challenge 2: Understanding Layer Caching +**Problem:** Initial builds took a long time even with minor code changes. + +**Solution:** Researched Docker layer caching and restructured Dockerfile to copy dependencies before code. Dramatically improved rebuild times. + +--- + +## What I Learned + +1. **Security by default:** Non-root users should be standard, not optional +2. **Layer order impacts build speed:** Docker's caching can save significant time +3. **Base image choice matters:** Balance between size, compatibility, and security +4. **Documentation is understanding:** Writing this doc helped solidify my Docker knowledge diff --git a/lab_solutions/lab1/app_python/docs/LAB03.md b/lab_solutions/lab1/app_python/docs/LAB03.md new file mode 100644 index 0000000000..36005c1467 --- /dev/null +++ b/lab_solutions/lab1/app_python/docs/LAB03.md @@ -0,0 +1,37 @@ +# LAB03 - Continuous Integration (CI/CD) + +## Overview +- **Testing framework:** pytest. It has concise assertions, great fixtures, and strong FastAPI support via TestClient +- **Coverage:** Tests verify `GET /`, `GET /health`, and 404 responses. They assert required JSON fields and data types. +- **Workflow triggers:** Runs on push and pull request changes inside `lab_solutions/lab1/app_python/`. +- **Versioning strategy:** CalVer (`YYYY.MM.DD`). It is simple for a service that is continuously deployed without strict release cycles. + +## Workflow Evidence +- **Successful workflow run:** https://github.com/Leropsis/DevOps-Core-Course-v/actions/runs/ +- **Docker image:** https://hub.docker.com/r/chupapupa/devops-info-service/tags +- **Status badge:** Added in `app_python/README.md` + +### Local test run +```bash +pip install -r requirements.txt -r requirements-dev.txt +pytest +``` + +## Best Practices Implemented +- **Dependency caching:** `actions/setup-python` with pip cache to reduce install time on repeated runs. +- **Fail fast:** If lint or tests fail, the workflow stops and Docker build is skipped. +- **Job dependencies:** Docker build job depends on tests (`needs: test`). +- **Least privilege:** Workflow permissions set to `contents: read`. +- **Path filters:** CI only runs when app files or the workflow change. +- **Concurrency:** Cancels previous runs on the same branch to avoid duplicate work. +- **Security scan:** Snyk scan on dependencies with a high severity threshold. + +## Key Decisions +- **Versioning Strategy:** CalVer for date-based releases that are easy to trace and automate. +- **Docker Tags:** `${VERSION}` and `latest` for clear versioned releases plus a default tag. +- **Workflow Triggers:** Path filters prevent unnecessary runs when unrelated files change. +- **Test Coverage:** Focus on endpoint behavior and response structure. + +## Challenges +- **Snyk setup:** Requires a `SNYK_TOKEN` GitHub secret. After adding the token, the scan runs successfully. +- **Docker Hub auth:** Requires `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` secrets for pushing images. diff --git a/lab_solutions/lab1/app_python/docs/screenshots/docker_build_1.png b/lab_solutions/lab1/app_python/docs/screenshots/docker_build_1.png new file mode 100644 index 0000000000..409c8ecc67 Binary files /dev/null and b/lab_solutions/lab1/app_python/docs/screenshots/docker_build_1.png differ diff --git a/lab_solutions/lab1/app_python/docs/screenshots/docker_build_2.png b/lab_solutions/lab1/app_python/docs/screenshots/docker_build_2.png new file mode 100644 index 0000000000..752d0d59bc Binary files /dev/null and b/lab_solutions/lab1/app_python/docs/screenshots/docker_build_2.png differ diff --git a/lab_solutions/lab1/app_python/docs/screenshots/docker_hub_1.png b/lab_solutions/lab1/app_python/docs/screenshots/docker_hub_1.png new file mode 100644 index 0000000000..13d0f14ee3 Binary files /dev/null and b/lab_solutions/lab1/app_python/docs/screenshots/docker_hub_1.png differ diff --git a/lab_solutions/lab1/app_python/docs/screenshots/docker_hub_2.png b/lab_solutions/lab1/app_python/docs/screenshots/docker_hub_2.png new file mode 100644 index 0000000000..13c9376327 Binary files /dev/null and b/lab_solutions/lab1/app_python/docs/screenshots/docker_hub_2.png differ diff --git a/lab_solutions/lab1/app_python/docs/screenshots/docker_run.png b/lab_solutions/lab1/app_python/docs/screenshots/docker_run.png new file mode 100644 index 0000000000..026838aaf9 Binary files /dev/null and b/lab_solutions/lab1/app_python/docs/screenshots/docker_run.png differ diff --git a/lab_solutions/lab1/app_python/docs/screenshots/get_call.png b/lab_solutions/lab1/app_python/docs/screenshots/get_call.png new file mode 100644 index 0000000000..5ccbeeaeed Binary files /dev/null and b/lab_solutions/lab1/app_python/docs/screenshots/get_call.png differ diff --git a/lab_solutions/lab1/app_python/docs/screenshots/health_call.png b/lab_solutions/lab1/app_python/docs/screenshots/health_call.png new file mode 100644 index 0000000000..a3dcfa8e06 Binary files /dev/null and b/lab_solutions/lab1/app_python/docs/screenshots/health_call.png differ diff --git a/lab_solutions/lab1/app_python/docs/screenshots/health_call_dockerized.png b/lab_solutions/lab1/app_python/docs/screenshots/health_call_dockerized.png new file mode 100644 index 0000000000..163380d5e9 Binary files /dev/null and b/lab_solutions/lab1/app_python/docs/screenshots/health_call_dockerized.png differ diff --git a/lab_solutions/lab1/app_python/docs/screenshots/logs.png b/lab_solutions/lab1/app_python/docs/screenshots/logs.png new file mode 100644 index 0000000000..ec31c17132 Binary files /dev/null and b/lab_solutions/lab1/app_python/docs/screenshots/logs.png differ diff --git a/lab_solutions/lab1/app_python/docs/screenshots/logs_dockerized.png b/lab_solutions/lab1/app_python/docs/screenshots/logs_dockerized.png new file mode 100644 index 0000000000..53833a936d Binary files /dev/null and b/lab_solutions/lab1/app_python/docs/screenshots/logs_dockerized.png differ diff --git a/lab_solutions/lab1/app_python/docs/screenshots/working_service.png b/lab_solutions/lab1/app_python/docs/screenshots/working_service.png new file mode 100644 index 0000000000..f171a0a71a Binary files /dev/null and b/lab_solutions/lab1/app_python/docs/screenshots/working_service.png differ diff --git a/lab_solutions/lab1/app_python/requirements-dev.txt b/lab_solutions/lab1/app_python/requirements-dev.txt new file mode 100644 index 0000000000..6a502c8be5 --- /dev/null +++ b/lab_solutions/lab1/app_python/requirements-dev.txt @@ -0,0 +1,4 @@ +pytest==8.3.4 +pytest-cov==5.0.0 +ruff==0.8.4 +httpx==0.27.2 diff --git a/lab_solutions/lab1/app_python/requirements.txt b/lab_solutions/lab1/app_python/requirements.txt new file mode 100644 index 0000000000..792449289f --- /dev/null +++ b/lab_solutions/lab1/app_python/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 diff --git a/lab_solutions/lab1/app_python/tests/__init__.py b/lab_solutions/lab1/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lab_solutions/lab1/app_python/tests/test_app.py b/lab_solutions/lab1/app_python/tests/test_app.py new file mode 100644 index 0000000000..baad6329f2 --- /dev/null +++ b/lab_solutions/lab1/app_python/tests/test_app.py @@ -0,0 +1,45 @@ +from fastapi.testclient import TestClient + +from app import app + + +client = TestClient(app) + + +def test_root_endpoint_structure(): + response = client.get("/", headers={"User-Agent": "pytest"}) + assert response.status_code == 200 + + data = response.json() + assert data["service"]["name"] == "devops-info-service" + assert data["service"]["framework"] == "FastAPI" + + assert "hostname" in data["system"] + assert "platform" in data["system"] + assert isinstance(data["system"]["cpu_count"], int) + + assert isinstance(data["runtime"]["uptime_seconds"], int) + assert "current_time" in data["runtime"] + + assert data["request"]["method"] == "GET" + assert data["request"]["path"] == "/" + + endpoints = {item["path"] for item in data["endpoints"]} + assert "/" in endpoints + assert "/health" in endpoints + + +def test_health_endpoint(): + response = client.get("/health") + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "healthy" + assert isinstance(data["uptime_seconds"], int) + assert isinstance(data["timestamp"], str) + + +def test_unknown_endpoint_returns_404(): + response = client.get("/does-not-exist") + assert response.status_code == 404 + assert response.json()["detail"] == "Not Found"