diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..a91b08ee9a --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,94 @@ +name: Python CI & Docker + +on: + push: + branches: [ main, master ] + tags: [ 'v*' ] + pull_request: + branches: [ main, master ] + +env: + APP_DIR: app_python + +jobs: + lint-and-test: + name: Lint, Test and Snyk + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r ${{ env.APP_DIR }}/requirements.txt + pip install -r ${{ env.APP_DIR }}/requirements-dev.txt + + - name: Lint + run: | + flake8 ${{ env.APP_DIR }} + + - name: Run tests + run: | + cd ${{ env.APP_DIR }} + pytest -q + + - name: Install Snyk + run: | + npm install -g snyk + + - name: Snyk test + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: | + snyk test --file=${{ env.APP_DIR }}/requirements.txt --severity-threshold=high || true + + docker-build-push: + name: Build and Push Docker Image + needs: lint-and-test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set version variables + id: vars + run: | + echo "GITHUB_REF=$GITHUB_REF" + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION=${GITHUB_REF#refs/tags/v} + else + VERSION="0.0.0-dev-${GITHUB_RUN_NUMBER}" + fi + echo "VERSION=$VERSION" >> $GITHUB_ENV + MAJOR_MINOR=$(echo $VERSION | awk -F. '{print $1"."$2}') + echo "MAJOR_MINOR=$MAJOR_MINOR" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: ./app_python + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:${{ env.VERSION }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:${{ env.MAJOR_MINOR }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:latest diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..2de2a67161 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,9 @@ +venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.git/ +.github/ +tests/ +docs/ +*.md diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..77ee791960 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,17 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# pytest +.pytest_cache/ +.coverage +coverage.xml \ No newline at end of file diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..afe28c32d0 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +RUN groupadd -r app && useradd -r -g app app && chown -R app:app /app + +USER app + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..8c5711b7e1 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,82 @@ +# DevOps Info Service + +![Python CI](https://github.com/iu-capstone-ad/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg) + +## Overview + +Python Flask service with endpoints for checking system information and health. + +## Prerequisites + +Python version 3.12 or higher, Flask 3.1.0. + +Project has been tested with python 3.12 and Flask 3.1.0 on Ubuntu 24.04 + +## Installation + +```bash +# clone repo +git clone https://github.com/iu-capstone-ad/DevOps-Core-Course +# cd into the app directory +cd app_python +# create and activate a new venv +python3 -m venv venv +source venv/bin/activate +# install dependencies from requirements.txt +pip install -r requirements.txt +``` + +## Running tests + +Install dev requirements and run pytest: + +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt -r requirements-dev.txt +pytest -q +``` + +## Running the Application + +```bash +python app.py +# or with custom config +PORT=8080 python app.py +``` + +## API Endpoints + +- `GET /` - Show system information. +- `GET /health` - Show health information (service uptime). + +## Configuration + +Environment Variables table + +| Variable | Default | Description | +|----------|-----------|--------------------------------------| +| `HOST` | `0.0.0.0` | Address for the service to listen on | +| `PORT` | `5000` | Port for the service to listen on | +| `DEBUG` | `False` | Enable Flask debug mode | + +## Docker + +### Building the image locally + +```bash +cd app_python +docker build -t iucapstonead/devops-info-service:lab02 . +``` + +### Running the container + +```bash +docker run -p 5000:5000 iucapstonead/devops-info-service:lab02 +``` + +### Pulling and running from docker hub + +```bash +docker pull iucapstonead/devops-info-service:lab02 +``` diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..43e03ae3fb --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,146 @@ +import os +import socket +import platform +import logging +from datetime import datetime, timezone + +from flask import Flask, jsonify, request + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +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() + + +def get_uptime(): + delta = datetime.now() - 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_platform_version(): + system = platform.system() + if system == "Linux": + return platform.freedesktop_os_release()["PRETTY_NAME"] + elif system == "Darwin": + return str(platform.mac_ver()[0]) + elif system == "Windows": + return platform.version() + return platform.release() + + +def get_system_info(): + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": get_platform_version(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version(), + } + + +def get_service_info(): + return { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask", + } + + +def get_runtime_info(): + uptime = get_uptime() + return { + "uptime_seconds": uptime["seconds"], + "uptime_human": uptime["human"], + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC", + } + + +def get_request_info(): + return { + "client_ip": request.remote_addr, + "user_agent": request.headers.get("User-Agent", "unknown"), + "method": request.method, + "path": request.path, + } + + +def get_endpoints(): + return [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + ] + + +@app.route("/") +def index(): + logger.info( + f"Request: {request.method} {request.path} from {request.remote_addr}" + ) + + return jsonify( + { + "service": get_service_info(), + "system": get_system_info(), + "runtime": get_runtime_info(), + "request": get_request_info(), + "endpoints": get_endpoints(), + } + ) + + +@app.route("/health") +def health(): + return jsonify( + { + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": get_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): + logger.error(f"Internal server error: {error}") + return ( + jsonify( + { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + } + ), + 500, + ) + + +if __name__ == "__main__": + logger.info("Starting...") + 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..b1b07c526f --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,158 @@ +# Lab 1 — Implementation Report + +## Framework Selection + +I chose Flask for this project because I am most familiar with it. + +Flask can be compared to another framework "FastAPI". It is similar to Flask in that both are minimal frameworks compared to another framework "Django". But FastAPI has more tools built-in compared to Flask, for example built-in async/await support, built-in API docs. + +| | Flask | FastAPI | Django | +|-|-------|---------|--------| +| Size | Minimal | Minimal (but more than Flask) | Many | +| Builtin async support | No | Yes | No | +| Builtin automatic API documentation | No | Yes | No | +| Release year | 2010 | 2018 | 2005 | + +## Best Practices Applied + +### Single responsibility principle, clear function names + +Single responsibility principle with clear functions names makes functions have only one purpose, that is clear from its name. + +```python +@app.route("/") +def index(): + logger.info( + f"Request: {request.method} {request.path} from {request.remote_addr}" + ) + + return jsonify( + { + "service": get_service_info(), + "system": get_system_info(), + "runtime": get_runtime_info(), + "request": get_request_info(), + "endpoints": get_endpoints(), + } + ) +``` + +This allows easy unit testing of every function, and allows for more code reuse. + +### No hardcoded values for deployment settings + +No hardcoded values for deployment settings. + +```bash +PORT=8080 HOST=0.0.0.0 DEBUG=false python app.py +``` + +This allows to change deployment settings without having to change the code and greatly simplifies deployment. + +### Pinned dependencies + +`requirements.txt` uses exact versions of packages instead of just package names. + +``` +Flask==3.1.0 +``` + +This helps to avoid errors related to different versions of packages. + +## API Documentation + +### Main endpoint + +```bash +curl -s http://localhost:5000/ | jq . +``` + +```json +{ + "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-28T19:17:00.960826+00:00", + "timezone": "UTC", + "uptime_human": "0 hours, 0 minutes", + "uptime_seconds": 5 + }, + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 8, + "hostname": "t14-devops", + "platform": "Linux", + "platform_version": "Ubuntu 24.04.3 LTS", + "python_version": "3.12.3" + } +} +``` + +### Health endpoint + +```bash +curl -s http://localhost:5000/health | jq +``` + +```json +{ + "status": "healthy", + "timestamp": "2026-01-28T19:19:24.302719+00:00", + "uptime_seconds": 148 +} +``` + +### Non existent endpoint + +```bash +curl -s http://localhost:5000/doesnotexist | jq +``` + +```json +{ + "error": "Not Found", + "message": "Endpoint does not exist" +} +``` + +## Testing Evidence + +- ![Main endpoint](screenshots/01-main-endpoint.png) +- ![Health endpoint](screenshots/02-health-check.png) +- ![Formatted output](screenshots/03-formatted-output.png) + +## Challenges & Solutions + +### Python3 venv was not preinstalled on my system + +Python 3 venv did not come preinstalled on Ubuntu 24.04 with zfsbootmenu, so I had to install it with the following command. + +```bash +sudo apt install python3.12-venv +``` + +## GitHub Community + +Starring repositories helps attract more users who might find the project helpful or attract potential project contributors. Also starring the repository makes it more likely that the project will be added to package repositories. diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..9aa22b4f19 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,134 @@ +# Lab 2 - Docker Containerization + +## Docker Best Practices Applied + +### Non-root user + +``` +RUN groupadd -r app && useradd -r -g app app && chown -R app:app /app +``` + +Created a separate non-root user "app" in the image and switch to this user with `USER app`. This reduces the damage that can be done if the application inside the container is compromised. + +### Layer caching + +`requirements.txt` is copied and installed before copying the application code so dependency changes and app code changes create different layers and builds reuse the dependency layer when possible. + +### Only copy necessary files & Use Dockerignore + +Only `requirements.txt` and `app.py` are copied. `.dockerignore` lists unnecessary files. This reduces the image size. + +### Specific base image + +``` +FROM python:3.12-slim +``` + +The image uses `python:3.12-slim` to get a maintained python3.12 image. Using a specific major python release in the base image helps with predictability. + +## Image Information & Decisions + +I chose `python:3.12-slim` as a base image since I have previously tested the application with python 3.12 . I chose the slim version to have a smalled build size. + +Final image size is 132MB which is only 13MB over the base `python:3.12-slim` image, that is 119MB. + +The image layer structure consists of first installing the dependencies, then copying app code. This allows to reuse the dependencies, and not reinstall dependencies on every code change. + +For optimization I have used `--no-cache-dir` in the dependency installation command `pip install --no-cache-dir -r requirements.txt`. In order to not cache the dependencies and to make the resulting build smaller. + +## Build & Run Process + +### Build image + +```bash +cd app_python +docker build -t iucapstonead/devops-info-service:lab02 . +``` + +Terminal output: + +``` +cirno@t14-devops:~/Documents/DevOps-Core-Course$ cd app_python +cirno@t14-devops:~/Documents/DevOps-Core-Course/app_python$ docker build -t iucapstonead/devops-info-service:lab02 . +[+] Building 29.9s (11/11) FINISHED docker:default + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 286B 0.0s + => [internal] load metadata for docker.io/library/python:3.12-slim 0.0s + => [internal] load .dockerignore 0.0s + => => transferring context: 113B 0.0s + => CACHED [1/6] FROM docker.io/library/python:3.12-slim 0.0s + => [internal] load build context 0.0s + => => transferring context: 63B 0.0s + => [2/6] WORKDIR /app 3.7s + => [3/6] COPY requirements.txt . 0.0s + => [4/6] RUN pip install --no-cache-dir -r requirements.txt 5.1s + => [5/6] COPY app.py . 5.1s + => [6/6] RUN groupadd -r app && useradd -r -g app app && chown -R app:app /app 15.9s + => exporting to image 0.1s + => => exporting layers 0.1s + => => writing image sha256:efac5b8d6f81148843d1b713144694aeca62ba8d6cef554d297c4410a64b6b12 0.0s + => => naming to docker.io/iucapstonead/devops-info-service:lab02 +``` + +### Running image + +```bash +docker run -p 5000:5000 iucapstonead/devops-info-service:lab02 +``` + +Terminal Output + +``` +cirno@t14-devops:~/Documents/DevOps-Core-Course/app_python$ docker run -p 5000:5000 iucapstonead/devops-info-service:lab02 +2026-02-04 20:24:09,443 - __main__ - INFO - Starting... + * Serving Flask app 'app' + * Debug mode: off +2026-02-04 20:24:09,446 - 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 20:24:09,446 - werkzeug - INFO - Press CTRL+C to quit +2026-02-04 20:24:33,523 - __main__ - INFO - Request: GET / from 172.17.0.1 +2026-02-04 20:24:33,525 - werkzeug - INFO - 172.17.0.1 - - [04/Feb/2026 20:24:33] "GET / HTTP/1.1" 200 - +2026-02-04 20:24:42,137 - __main__ - INFO - Request: GET / from 172.17.0.1 +2026-02-04 20:24:42,138 - werkzeug - INFO - 172.17.0.1 - - [04/Feb/2026 20:24:42] "GET / HTTP/1.1" 200 - +``` + +Curl testing the main endpoint + +```bash +curl localhost:5000 +``` + +Curl output + +``` +{"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-04T20:24:42.137455+00:00","timezone":"UTC","uptime_human":"0 hours, 0 minutes","uptime_seconds":32},"service":{"description":"DevOps course info service","framework":"Flask","name":"devops-info-service","version":"1.0.0"},"system":{"architecture":"x86_64","cpu_count":8,"hostname":"bdbdc916a90f","platform":"Linux","platform_version":"Debian GNU/Linux 13 (trixie)","python_version":"3.12.12"}} +``` + +### Docker Hub repository URL + +https://hub.docker.com/r/iucapstonead/devops-info-service + + +## Technical Analysis + +### Why Dockerfile order works + +Installing dependencies first produces a layer that is reused when only application code changes. Copying only requirements and installing prevents copying source files that would invalidate the cache. + +### If order changed + +If order was changed to be copy all application source code and the perform pip install, any change to the source code would invalidate the dependencies cache and require running pip install on every build, slowing down build times. + +### Security Considerations + +Running application as a dedicated non-root app user reduces the attack surface. Using a minimal base image reduces the attack surface. + +### Dockerfile benefits + +Excluding venv .git and other files reduces the amount of data sent to the Docker daemon, speeding up build times. + +## Challenges + +No challenges were faced during this lab. Everything was done without any issues. diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..a612866d9c --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,93 @@ +# Lab 03 - Continuous Integration (CI/CD) + +## Overview + +Testing and CI are added to the Python service. The pipeline runs linting tests, unit tests, coverage tests, Snyk scan, and builds/pushes Docker images using a SemVer strategy. + +### Testing + +- Text framework - `pytest`: lightweight, fixture support and good plugin ecosystem. +- Tests are in `app_python/tests/` directory: covering `GET /`, `GET /health` endpoints and a 404 case. + +Running tests locally: + +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt -r requirements-dev.txt +pytest -q +``` + +### CI Trigger + +Workflow: `.github/workflows/python-ci.yml` +Runs on `push` to `main`/`master`, on `pull_request`, and on `push` of tags matching `v*`. + +### Versioning Strategy + +Chosen: Semantic Versioning. A tag `v1.2.3` will create a Docker image tagged as `1.2.3`, `1.2`, and `latest`. For non-tagged commits it uses development tag `0.0.0-dev-`. + +## Workflow Evidence + +### Workflow file + +`.github/workflows/python-ci.yml` + +### Local tests: + +``` +(venv) cirno@t14-devops:~/Documents/DevOps-Core-Course/app_python$ pytest -q +... [100%] +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.12.3-final-0 ________________ + +Name Stmts Miss Cover +---------------------------- +app.py 56 9 84% +---------------------------- +TOTAL 56 9 84% +Coverage XML written to file coverage.xml +3 passed in 0.28s +``` + +# Docker image + +https://hub.docker.com/r/iucapstonead/devops-info-service + +The image that was built and pushed by the CI/CD is `v0.3.1`. + +# Status badge + +Added to `app_python/README.md`. + +## Best Practices Implemented + +### Dependency caching + +`actions/cache` caches pip packages based on `requirements.txt` hash. This helps to reduce install time. + +### Fail-fast pipeline + +Docker build/push runs only after lint and tests succeed. + +### Security scanning + +Snyk is used in CI to check Python dependencies for known vulnerabilities. + +## Key Decisions + +### Versioning Strategy + +Semantic versioning was chosen because it allows to version with major and minor releases. Making the use of images easier. + +### Docker Tags + +For tagged releases CI creates `username/devops-info-service:1.2.3`, `username/devops-info-service:1.2`, and `username/devops-info-service:latest`. + +### Workflow Triggers + +`push` to `main`/`master`, PRs and tag pushes. + +### Test Coverage + +Unit tests cover main endpoints and a 404 path; coverage is reported via `pytest-cov` and an XML report is produced. 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..9366579ebe 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..72b92a1a23 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..1e62bcba52 Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/app_python/pytest.ini b/app_python/pytest.ini new file mode 100644 index 0000000000..59393d91fd --- /dev/null +++ b/app_python/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = --cov=app --cov-report=term --cov-report=xml +testpaths = tests diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..172f1bb886 --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest==9.0.2 +pytest-cov==7.0.0 +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/test_app.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..e60ab46734 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,53 @@ +import pytest + +from app import app as flask_app + + +@pytest.fixture +def client(): + flask_app.config.update(TESTING=True) + with flask_app.test_client() as client: + yield client + + +def test_index_structure(client): + resp = client.get("/") + assert resp.status_code == 200 + data = resp.get_json() + # top-level keys + assert "service" in data + assert "system" in data + assert "runtime" in data + assert "request" in data + assert "endpoints" in data + + # service fields + svc = data["service"] + assert isinstance(svc.get("name"), str) + assert isinstance(svc.get("version"), str) + + # system fields + sys = data["system"] + assert "hostname" in sys + assert "platform" in sys + + # runtime fields + rt = data["runtime"] + assert "uptime_seconds" in rt + assert "current_time" in rt + + +def test_health_endpoint(client): + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.get_json() + assert data.get("status") == "healthy" + assert "timestamp" in data + assert isinstance(data.get("uptime_seconds"), int) + + +def test_404_error(client): + resp = client.get("/does/not/exist") + assert resp.status_code == 404 + data = resp.get_json() + assert data.get("error") == "Not Found"