diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..ecf40d9869 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/__pycache__/ +*.py[cod] +*$py.class +**/.env +**/.venv +**/env/ +**/venv/ +.git/ +.gitignore +tests/ +docs/ diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..b675f16f77 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,101 @@ +name: Python CI + +on: + push: + branches: [master, main, lab03] + paths: ['app_python/**', '.github/workflows/python-ci.yml'] + pull_request: + branches: [master, main] + paths: ['app_python/**', '.github/workflows/python-ci.yml'] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v4 + with: + python-version: "3.13" + cache: 'pip' + cache-dependency-path: | + app_python/requirements.txt + app_python/requirements-dev.txt + + - name: Install dependencies + working-directory: app_python + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Lint with flake8 + working-directory: app_python + run: | + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 . --count --exit-zero --max-complexity=10 --statistics + + - name: Test with pytest + working-directory: app_python + run: pytest tests/pytest.py -v + + + security: + name: Security Scan + runs-on: ubuntu-latest + defaults: + run: + working-directory: app_python + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: 'pip' + cache-dependency-path: 'app_python/requirements.txt' + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements.txt + + - name: Set up Snyk + uses: snyk/actions/setup@master + + - name: Run Snyk to check for vulnerabilities + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: snyk test --package-manager=pip --file=requirements.txt --severity-threshold=high + + docker: + needs: [test, security] + # if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Extract version + id: version + run: | + VERSION=$(grep '^APP_VERSION' app_python/app.py | cut -d'"' -f2) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - 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: . + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:${{ steps.version.outputs.version }},${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..54cdd93979 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.12-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY app_python/requirements.txt . + +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +FROM python:3.12-slim + +WORKDIR /app + +RUN groupadd -r appuser && useradd -r -g appuser appuser + +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +COPY app_python/app.py . + +RUN chown -R appuser:appuser /app + +USER appuser + +EXPOSE 5000 + +ENV HOST=0.0.0.0 \ + PORT=5000 + +CMD ["python", "app.py"] + diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..ffb798c533 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,5 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +*.log diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..e04efee564 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,52 @@ +# DevOps Info Service +[![Python CI](https://github.com/saddogsec/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](...) +## Overview +This service reports: +- Service metadata (name, version, framework) +- System data (hostname, operating system, cpu, python version) +- Runtime data (uptime and current UTC time) +- Request metadata (IP, user agent, method, path) + +## Prerequisites +- Python 3.11+ + +## Installation +```bash +cd app_python +python -m venv venv +source venv/bin/activate # or source `venv/bin/activate.fish` if you using fish instead of bash/sh. +pip install -r requirements.txt +``` + +## Running the Application +```bash +python app.py + +# Or with custom config +PORT=8080 python app.py +``` + +## API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check + +## Configuration +`HOST` - Interface to bind (0.0.0.0 by default) + +`PORT` - Port to listen on (5000 by default) + +`DEBUG` - Enable Flask debug logging (False by default) + +## Docker +- **Build image:** +``` +docker build -t . +``` +- **Get image from dockerhub:** +``` +docker pull saddogsec/devops-info-service:1.0.0 +``` +- **Run the image** +``` +docker run -p 5000:5000 saddogsec/devops-info-service:1.0.0 +``` diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..755e88066e --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,152 @@ +import logging +import os +import platform +import socket +from datetime import datetime, timezone +from flask import Flask, jsonify, request + +APP_NAME = "devops-info-service" +APP_VERSION = "1.0.0" +APP_DESCRIPTION = "DevOps course info service" +FRAMEWORK = "Flask" + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "5000")) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + +START_TIME = datetime.now(timezone.utc) + + +def iso_utc_z(dt: datetime) -> str: + utc_dt = dt.astimezone(timezone.utc) + return utc_dt.isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def get_uptime() -> dict: + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + hour_label = "hour" if hours == 1 else "hours" + minute_label = "minute" if minutes == 1 else "minutes" + return { + "seconds": seconds, + "human": f"{hours} {hour_label}, {minutes} {minute_label}", + } + + +def get_client_ip() -> str: + forwarded = request.headers.get("X-Forwarded-For", "") + if forwarded: + return forwarded.split(",")[0].strip() + return request.remote_addr or "unknown" + + +def get_system_info() -> dict: + 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_runtime_info() -> dict: + uptime = get_uptime() + now_utc = datetime.now(timezone.utc) + return { + "uptime_seconds": uptime["seconds"], + "uptime_human": uptime["human"], + "current_time": iso_utc_z(now_utc), + "timezone": "UTC", + } + + +def get_request_info() -> dict: + return { + "client_ip": get_client_ip(), + "user_agent": request.headers.get("User-Agent", ""), + "method": request.method, + "path": request.path, + } + + +def get_endpoints() -> list[dict]: + return [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + ] + + +def create_app() -> Flask: + app = Flask(__name__) + + logging.basicConfig( + level=logging.DEBUG if DEBUG else logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + logger = logging.getLogger(__name__) + + @app.before_request + def log_request() -> None: + logger.debug("Request: %s %s", request.method, request.path) + + @app.get("/") + def index(): + payload = { + "service": { + "name": APP_NAME, + "version": APP_VERSION, + "description": APP_DESCRIPTION, + "framework": FRAMEWORK, + }, + "system": get_system_info(), + "runtime": get_runtime_info(), + "request": get_request_info(), + "endpoints": get_endpoints(), + } + return jsonify(payload) + + @app.get("/health") + def health(): + uptime = get_uptime() + return jsonify( + { + "status": "healthy", + "timestamp": iso_utc_z(datetime.now(timezone.utc)), + "uptime_seconds": uptime["seconds"], + } + ) + + @app.errorhandler(404) + def not_found(_error): + return ( + jsonify( + { + "error": "Not Found", + "message": "Endpoint does not exist", + } + ), + 404, + ) + + @app.errorhandler(500) + def internal_error(_error): + return ( + jsonify( + { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + } + ), + 500, + ) + + return app + + +if __name__ == "__main__": + app = create_app() + 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..17c2aa8c7f --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,57 @@ +# Lab 1 — DevOps Info Service (Python) + +## Framework Selection +- **Choice:** Flask +- **Why:** Easiest to implement and support, has enough functionality for required software + +### Comparison Table +| Option | Pros | Cons | +|-------|------|------| +| **Flask** | Simple, flexible, quick to implement | Less “built-in” structure | +| **FastAPI** | Async-ready, auto OpenAPI docs | Slightly more concepts upfront | +| **Django** | Full stack batteries included | Overkill for 2 endpoints | + +## Best Practices Applied +- **Clean code organization:** standartized code style with meaningful names. + This ensures other developers can understand what's happening right away. +- **Configuration via env vars:** `HOST`, `PORT`, `DEBUG`. + Env variables lets change programm behaviour without changing any code. +- **Logging:** basic logging + Logs are critical for debugging in production; they show what requests were made and help trace issues after they happen. +- **Error handling:** `404` and `500` responses. + Errors wont cause programm to stop, crash, or cause any unexpected behaviour. +- **Reproducible deps:** pinned `Flask==3.1.0` in `requirements.txt`. + Using specific version in requirements.txt ensures the software works on different machines, and won't break due to version mismatches +## API Documentation +### `GET /` +Returns service metadata, system info, runtime info, request info, and endpoints list. +Example response: +```bash +{"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":"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0"},"runtime":{"current_time":"2026-01-28T14:54:41.735Z","timezone":"UTC","uptime_human":"0 hours, 0 minutes","uptime_seconds":22},"service":{"description":"DevOps course info service","framework":"Flask","name":"devops-info-service","version":"1.0.0"},"system":{"architecture":"x86_64","cpu_count":8,"hostname":"host","platform":"Linux","platform_version":"Linux-6.17.13-hardened1-2-hardened-x86_64-with-glibc2.42","python_version":"3.14.2"}}``` +``` +### `GET /health` +Returns health status, UTC timestamp, and uptime seconds. +Example response: +```bash +{"status":"healthy","timestamp":"2026-01-28T14:54:24.458Z","uptime_seconds":4}``` +``` +## Testing Commands +```bash +cd app_python +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +python app.py +curl -s http://localhost:5000/ | jq . +curl -s http://localhost:5000/health | jq . +``` + +## Testing Evidence +All screenshots are located in `app_python/docs/screenshots/` + +## Challenges & Solutions +No challenges were encountered during the implementation. + +## GitHub Community +Starring repos helps to discover them, and add some "trust" to using software in them. Following developers just notifies you about their activity. diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..86e6ac50d7 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,174 @@ +# Lab 2 — Docker Containerization + +## Docker Best Practices Applied +- **Specific Base Image:** Using python:3.12-slim specifying the version ensures predictable environment +``` +FROM python:3.12-slim AS builder +... +FROM python:3.12-slim +``` +- **Layer Ordering:** Dockerfile is written with such layer, that Docker won't change layers, unless it's neccessary. So changing code in app.py, wont require to reinstall dependencies, unless the dependencies changes. +``` +# Dependencies install layer +FROM python:3.12-slim AS builder +... +COPY app_python/requirements.txt . + +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Running programm +FROM python:3.12-slim +... +CMD ["python", "app.py"] +``` +- **Non-Root User:** Using non-root user decreases security risks of breakout, and possibility of it. +``` +RUN groupadd -r appuser && useradd -r -g appuser appuser +... +RUN chown -R appuser:appuser /app +... +USER appuser +... + +``` +- **Copying only necessary:** Dockerfile don't copy any files, which excessive for running app.py, so image remain small in size. +``` +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +COPY app_python/app.py . +``` +## Image Information & Decisions +- **Base image:** python:3.12-slim used, to decrease the image size. +- **Image size:** final image size is 199mb, which mostly comes from basic `python:3.12-slim` image size +- **Layer structure explanation:** - If any action at some layer starts to produce different result, all layers below computed again, so current layer order first import requirements, which changes not frequently. Then creates user and uses it instead of root, this is almost-never changing layers, but requirements install frequently needs root privileges, so user creation happens after. Then it's just copying actual code and running it, this part changing almost every time, so those layers are the last +- **Optimization choices:** follow best practices, use small slim base image, copy only neccessary files, use proper layer ordering. + +## Build & Run Process +- **Complete terminal output from build process:** +``` +$ docker build -t devops-info-service . +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 927.7kB +Step 1/16 : FROM python:3.12-slim AS builder + ---> 87b49ee9d18d +Step 2/16 : ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 + ---> Using cache + ---> 07011a084c7f +Step 3/16 : WORKDIR /app + ---> Using cache + ---> 77152fff0ae8 +Step 4/16 : COPY app_python/requirements.txt . + ---> Using cache + ---> 55913bacbad6 +Step 5/16 : RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt + ---> Using cache + ---> c326bfd9e275 +Step 6/16 : FROM python:3.12-slim + ---> 87b49ee9d18d +Step 7/16 : WORKDIR /app + ---> Using cache + ---> 27090590b024 +Step 8/16 : RUN groupadd -r appuser && useradd -r -g appuser appuser + ---> Using cache + ---> c42c4931ec5a +Step 9/16 : COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages + ---> Using cache + ---> fb1db854b117 +Step 10/16 : COPY --from=builder /usr/local/bin /usr/local/bin + ---> Using cache + ---> cb02a27cc5be +Step 11/16 : COPY app_python/app.py . + ---> Using cache + ---> 137688dba132 +Step 12/16 : RUN chown -R appuser:appuser /app + ---> Using cache + ---> 11a49be0286d +Step 13/16 : USER appuser + ---> Using cache + ---> 6939ead36931 +Step 14/16 : EXPOSE 5000 + ---> Using cache + ---> 8fc6c2ddeea1 +Step 15/16 : ENV HOST=0.0.0.0 PORT=5000 + ---> Using cache + ---> ee8d4cbbdd7b +Step 16/16 : CMD ["python", "app.py"] + ---> Using cache + ---> e1e286cbcbbe +Successfully built e1e286cbcbbe +Successfully tagged devops-info-service:latest + +``` +- **Terminal output showing container running:** +``` +docker ps +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +3da1dbdf4144 devops-info-service:1.0.0 "python app.py" 4 seconds ago Up 3 seconds 5000/tcp sad_proskuriakova +``` +- **Terminal output from testing endpoints (curl/httpie):** +``` +$ curl -s http://localhost:5000/ | jq . +{ + "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.18.0" + }, + "runtime": { + "current_time": "2026-02-04T12:09:53.157Z", + "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": 8, + "hostname": "292ee90a8d4c", + "platform": "Linux", + "platform_version": "Linux-6.17.13-hardened1-2-hardened-x86_64-with-glibc2.41", + "python_version": "3.12.12" + } +} + +$ curl -s http://localhost:5000/health | jq . +{ + "status": "healthy", + "timestamp": "2026-02-04T12:09:58.563Z", + "uptime_seconds": 51 +} +``` +- **Docker Hub repository URL:** https://hub.docker.com/repository/docker/saddogsec/devops-info-service + +## Technical Analysis +- **Why does your Dockerfile work the way it does?** Because water is wet, sky is blue, and people who made Docker, made that it works the way it does +- **What would happen if you changed the layer order?** Depends on how. One good idea is to move user creation above requirements install, which after some tweaks make image more hardened, but introduce more complexity. Moving requirements install below the copying app file, would force to compute again requirements install layer, even if requirements havent changed. +- **What security considerations did you implement?** Use non-root user for running the app. +- **How does .dockerignore improve your build?** It ensures that unneccessary files wont be copied into image, reducing image size. + +## Challenges & Solutions +- **Issues encountered during implementation. How you debugged and resolved them** No issues were encountered during implementation. +- **What you learned from the process** It's not my first time doing this, so actually nothing new. diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..9b913b13c7 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,184 @@ +# Lab 03 — Continuous Integration & Automation + +## 1. Testing Strategy & Framework + +### Framework Selection: pytest + +The testing framework **pytest** was chosen over alternatives (unittest, nose) for the following reasons: + +- **Modern Pythonic syntax**: Uses simple `assert` statements instead of verbose `assertEqual()` methods +- **Powerful fixtures**: Clean test setup/teardown and dependency injection +- **FastAPI integration**: Works seamlessly with FastAPI's TestClient without server startup +- **Plugin ecosystem**: Excellent support for coverage, parallel execution, and reporting +- **Industry standard**: Most widely used in modern Python projects + +### Test Coverage + +4 tests were made to test both existing endpoints and error handling at invalid endpoint covering most of service functionality + +### Test Execution + +```bash +$ pytest app_python/tests/pytest.py -v +=============================================================================================== test session starts ================================================================================================ +... +app_python/tests/pytest.py::test_index_endpoint PASSED [ 25%] +app_python/tests/pytest.py::test_health_endpoint PASSED [ 50%] +app_python/tests/pytest.py::test_404_not_found PASSED [ 75%] +app_python/tests/pytest.py::test_iso_utc_z PASSED [100%] + +================================================================================================ 4 passed in 0.10s ================================================================================================= +``` + +--- + +## 2. GitHub Actions CI Pipeline + +### Workflow File Location + +`.github/workflows/python-ci.yml` + +### Workflow Architecture + +**3 Jobs with smart dependencies:** + +1. **Test and Lint** (ubuntu-latest) + - Python 3.13 setup with pip caching + - Install dependencies + flake8 + pytest + - Run flake8 + - Run pytest + +2. **Security Scan** (runs in parallel) + - Snyk vulnerability scanning + - Check only for HIGH/CRITICAL CVEs + - Report if any dependencies failed + +3. **Docker Build and Push** (depends on both previous jobs) + - Authenticate to Docker Hub + - Build image with caching + - Tag and push + +### Trigger Configuration + +```yaml +on: + push: + branches: [master, main, lab03] + paths: ['app_python/**', '.github/workflows/python-ci.yml'] + pull_request: + branches: [master, main] + paths: ['app_python/**', '.github/workflows/python-ci.yml'] +``` + +**Rationale**: Path filtering prevents unnecessary runs on documentation changes. + +### Docker Image Versioning + +**Strategy**: SemVer versioning + +**Why SemVer over CalVer?** +- Carries more information about changes than CalVer +- Am assuming rare updates for this software + +**Tags per image**: +- `latest` - Points to most recent build +- `1.0.0` - SemVer tag + +--- + +## 3. CI Best Practices & Optimizations + +### Practice 1: Status Badge + +Added to `app_python/README.md`: + +[![Python CI](https://github.com/saddogsec/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](...) + +Provides real-time visibility of pipeline status (passing/failing). + +### Practice 2: Dependency Caching + +Caching and requirements-dev.txt used to decrease pipeline execution time + +```yaml +cache: 'pip' +cache-dependency-path: | + app_python/requirements.txt + app_python/requirements-dev.txt +``` + +### Practice 3: Security Scanning with Snyk + +Dedicated job scans `requirements.txt` for vulnerabilities: + +```yaml +- name: Run Snyk to check for vulnerabilities + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: snyk test --package-manager=pip --file=requirements.txt --severity-threshold=high +``` + +### Practice 4: Parallel Job Execution + +Test and security jobs run in parallel instead of sequentially considerably decreasing pipeline execution time. + +### Practice 5: Job Dependencies with Fail-Fast + +```yaml +docker: + needs: [test, security] + if: github.ref == 'refs/heads/master' +``` + +### Practice 6: Secure secret management +Github secrets is used instead of .env or other vulnerable ways to store SNYK_TOKEN and DOCKER_TOKEN +```yaml +env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} +... +with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} +``` +--- + +## 4. Technical Analysis + +### Why This Pipeline Works +It uses standart approach, with multiple parallel tests which all needs to pass before creating deployment artifact +``` +Push → Tests pass? and Security scan completes? → Docker build & push + (required) (required) (only on relevant branches) +``` + +### Layer Caching Impact + +Docker layers are cached by GitHub Actions. On subsequent runs: +- Base image: reused +- Dependencies: reused (if requirements.txt unchanged) +- Application code: rebuilt (changed) +- **Result**: faster builds + +## 5. Key Decisions & Rationale + +### Decision 1: CalVer vs SemVer Versioning + +**Chosen**: SemVer versioning + +**Why SemVer over CalVer?** +- Easy to identify whenever something important changed + +--- + +### Decision 2: Snyk Severity Threshold + +**Chosen**: HIGH (fail only on HIGH/CRITICAL) + +**Rationale**: +- MEDIUM/LOW issues are hard to exploit in this software, so they seem irrelevant +- Considering Low issues would extremely limit amount of available libraries to use + +--- + +## 6. Challenges & Solutions +No challenges were present during the lab diff --git a/app_python/docs/screenshots/01-main-endpoint.jpg b/app_python/docs/screenshots/01-main-endpoint.jpg new file mode 100644 index 0000000000..72b0615818 Binary files /dev/null and b/app_python/docs/screenshots/01-main-endpoint.jpg differ diff --git a/app_python/docs/screenshots/02-health-check.jpg b/app_python/docs/screenshots/02-health-check.jpg new file mode 100644 index 0000000000..f0579f2d4d Binary files /dev/null and b/app_python/docs/screenshots/02-health-check.jpg differ diff --git a/app_python/docs/screenshots/03-formatted-output.jpg b/app_python/docs/screenshots/03-formatted-output.jpg new file mode 100644 index 0000000000..b1e59b7e84 Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.jpg differ diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..86009df6cc --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,3 @@ +Flask==3.1.0 +pytest==8.3.4 +flake8==7.3.0 diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..22ac75b399 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1 @@ +Flask==3.1.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/pytest.py b/app_python/tests/pytest.py new file mode 100644 index 0000000000..38360f95e7 --- /dev/null +++ b/app_python/tests/pytest.py @@ -0,0 +1,40 @@ +import pytest +import json +from datetime import datetime, timezone +from unittest.mock import patch, MagicMock +from app import create_app, iso_utc_z, get_uptime + +@pytest.fixture +def client(): + app = create_app() + app.config['TESTING'] = True + with app.test_client() as client: + yield client + +def test_index_endpoint(client): + response = client.get('/') + assert response.status_code == 200 + data = json.loads(response.data) + assert data['service']['name'] == 'devops-info-service' + assert 'system' in data + assert 'runtime' in data + +def test_health_endpoint(client): + response = client.get('/health') + assert response.status_code == 200 + data = json.loads(response.data) + assert data['status'] == 'healthy' + assert 'uptime_seconds' in data + +def test_404_not_found(client): + response = client.get('/nonexistent') + assert response.status_code == 404 + data = json.loads(response.data) + assert data['error'] == 'Not Found' + +def test_iso_utc_z(): + from app import iso_utc_z + dt = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + result = iso_utc_z(dt) + assert result.endswith('Z') + assert 'T' in result