Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions app_python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ The service will start on `http://HOST:PORT` (default `0.0.0.0:5000`).
curl http://127.0.0.1:5000/health
```

- `GET /visits`
- **Description**: Returns the persistent visits counter (file-backed, survives restarts). Incremented on every `GET /` request.
- **Example**:
```bash
curl http://127.0.0.1:5000/visits
```

- `GET /config`
- **Description**: Returns the JSON content of the mounted ConfigMap file at `$CONFIG_FILE` plus selected env vars sourced from a ConfigMap.

## Configuration

The application is configurable via environment variables:
Expand All @@ -70,6 +80,8 @@ The application is configurable via environment variables:
| `HOST` | `0.0.0.0` | Interface the server binds to |
| `PORT` | `5000` | TCP port the server listens on |
| `DEBUG` | `False` | Enables FastAPI/uvicorn reload when `true` |
| `DATA_DIR` | `/data` | Directory where the visits counter file is stored |
| `CONFIG_FILE` | `/config/config.json` | Path to a JSON config file mounted via ConfigMap |

Example:

Expand Down Expand Up @@ -118,6 +130,24 @@ ruff check .

**Pull from Docker Hub:** `docker pull <your-dockerhub-username>/<repo-name>:<tag>` then run as above.

### Docker Compose (persistent visits)

A `docker-compose.yml` bind-mounts `./visits-data` into the container at `/app/data` so the
visits counter survives container restarts.

```bash
cd app_python
mkdir -p visits-data
# container runs as non-root (uid 1000) so make the bind mount writable
sudo chown -R 1000:1000 visits-data || true
docker compose up --build -d
curl -s http://127.0.0.1:5000/ # increments counter
curl -s http://127.0.0.1:5000/visits # read-only
cat ./visits-data/visits # counter is persisted on host
docker compose restart devops-info
curl -s http://127.0.0.1:5000/visits # value is preserved across restart
```

## CI/CD

This project uses GitHub Actions for continuous integration and deployment:
Expand Down
77 changes: 76 additions & 1 deletion app_python/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,31 @@
Now emits structured JSON logs for easier aggregation.
"""

import asyncio
import json
import logging
import os
import platform
import socket
import tempfile
from pathlib import Path
from time import perf_counter
from datetime import UTC, datetime
from typing import Any

import uvicorn
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, generate_latest
from starlette.exceptions import HTTPException as StarletteHTTPException

# Configuration

Check failure on line 26 in app_python/app.py

View workflow job for this annotation

GitHub Actions / Test & Lint

Ruff (I001)

app.py:8:1: I001 Import block is un-sorted or un-formatted
HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", 5000))
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
DATA_DIR: str = os.getenv("DATA_DIR", "/data")
VISITS_FILE: Path = Path(DATA_DIR) / "visits"
CONFIG_FILE: str = os.getenv("CONFIG_FILE", "/config/config.json")


class JSONFormatter(logging.Formatter):
Expand Down Expand Up @@ -68,10 +74,52 @@

app = FastAPI(title="DevOps Info Service")

_visits_lock = asyncio.Lock()


def _read_visits() -> int:
"""Read visits counter from file, default to 0 if missing/invalid."""
try:
return int(VISITS_FILE.read_text().strip() or "0")
except (FileNotFoundError, ValueError):
return 0


def _write_visits(value: int) -> None:
"""Atomically write visits counter (tmp file + rename)."""
VISITS_FILE.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=str(VISITS_FILE.parent), prefix=".visits.")
try:
with os.fdopen(fd, "w") as f:
f.write(str(value))
os.replace(tmp_path, VISITS_FILE)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass

Check failure on line 100 in app_python/app.py

View workflow job for this annotation

GitHub Actions / Test & Lint

Ruff (SIM105)

app.py:97:9: SIM105 Use `contextlib.suppress(OSError)` instead of `try`-`except`-`pass`
raise


async def _increment_visits() -> int:
async with _visits_lock:
new_value = _read_visits() + 1
_write_visits(new_value)
return new_value


def _load_config_file() -> dict[str, Any]:
"""Best-effort read of the mounted ConfigMap config file."""
try:
with open(CONFIG_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}


def normalize_endpoint(path: str) -> str:
"""Normalize endpoint labels to keep metric cardinality predictable."""
if path in {"/", "/health", "/metrics"}:
if path in {"/", "/health", "/metrics", "/visits", "/config"}:
return path
return "/other"

Expand Down Expand Up @@ -184,6 +232,8 @@
{"path": "/", "method": "GET", "description": "Service information"},
{"path": "/health", "method": "GET", "description": "Health check"},
{"path": "/metrics", "method": "GET", "description": "Prometheus metrics"},
{"path": "/visits", "method": "GET", "description": "Persistent visits counter"},
{"path": "/config", "method": "GET", "description": "Mounted ConfigMap content"},
]


Expand All @@ -204,6 +254,7 @@
system_info = get_system_info()
runtime_info = get_runtime_info()
request_info = get_request_info(request)
visits = await _increment_visits()

response: dict[str, Any] = {
"service": {
Expand All @@ -215,11 +266,35 @@
"system": system_info,
"runtime": runtime_info,
"request": request_info,
"visits": visits,
"endpoints": get_endpoints(),
}
return response


@app.get("/visits")
async def visits() -> dict[str, Any]:
"""Return current persistent visits counter without incrementing."""
ENDPOINT_CALLS_TOTAL.labels(endpoint="/visits").inc()
return {"visits": _read_visits(), "file": str(VISITS_FILE)}


@app.get("/config")
async def config() -> dict[str, Any]:
"""Return the mounted ConfigMap content and selected env vars."""
ENDPOINT_CALLS_TOTAL.labels(endpoint="/config").inc()
return {
"file_path": CONFIG_FILE,
"file_content": _load_config_file(),
"env": {
"APP_ENV": os.getenv("APP_ENV"),
"LOG_LEVEL": os.getenv("LOG_LEVEL"),
"FEATURE_FLAG_BETA": os.getenv("FEATURE_FLAG_BETA"),
"WELCOME_MESSAGE": os.getenv("WELCOME_MESSAGE"),
},
}


@app.get("/health")
async def health() -> dict[str, Any]:
"""Health check endpoint."""
Expand Down
15 changes: 15 additions & 0 deletions app_python/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
services:
devops-info:
build: .
container_name: devops-info
ports:
- "5000:5000"
environment:
HOST: "0.0.0.0"
PORT: "5000"
DATA_DIR: "/app/data"
APP_ENV: "local"
LOG_LEVEL: "info"
volumes:
- ./visits-data:/app/data
restart: unless-stopped
1 change: 1 addition & 0 deletions app_python/visits-data/visits
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
11
Loading
Loading