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
24 changes: 23 additions & 1 deletion app_python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

[![python-ci](https://github.com/GrayMansion/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/GrayMansion/DevOps-Core-Course/actions/workflows/python-ci.yml)

A small web service that exposes system/runtime information and a health check endpoint.
A small web service that exposes system/runtime information, metrics, and a persistent visits counter.

## Prerequisites
- Python 3.11+
Expand Down Expand Up @@ -32,7 +32,9 @@ DEBUG=true python app.py
## API Endpoints
```bash
GET / — Service and system information
GET /visits — Current visits counter value
GET /health — Health check
GET /metrics — Prometheus metrics
```

## Configuration
Expand All @@ -41,6 +43,7 @@ GET /health — Health check
| HOST | 0.0.0.0 | Bind address |
| PORT | 5000 | Listening port |
| DEBUG | False | If true, enables reload + debug logging |
| VISITS_FILE | ./data/visits | File path for persistent visits counter |

## Docker

Expand All @@ -64,3 +67,22 @@ docker pull graymansion/devops-info-service:lab02
docker run --rm -p <host_port>:<container_port> graymansion/devops-info-service:lab02
```

## Local persistence test with Docker Compose

Run from app_python directory:

```bash
docker compose up --build -d
curl http://localhost:5000/
curl http://localhost:5000/
curl http://localhost:5000/visits
cat ./data/visits
docker compose restart
curl http://localhost:5000/visits
docker compose down
```

Expected result:
- Counter increments when / is called.
- The same counter value is kept after container restart because ./data is mounted to /app/data.

52 changes: 52 additions & 0 deletions app_python/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import platform
import socket
import time
from pathlib import Path
from threading import Lock
from datetime import datetime, timezone

import uvicorn
Expand All @@ -24,6 +26,7 @@
PORT = int(os.getenv("PORT", "5000"))
DEBUG = os.getenv("DEBUG", "False").lower() == "true"
LOG_FORMAT = os.getenv("LOG_FORMAT", "text") # "json" for structured logging
VISITS_FILE = os.getenv("VISITS_FILE", "./data/visits")


# -------------------------
Expand Down Expand Up @@ -97,6 +100,35 @@ def format(self, record: logging.LogRecord) -> str:
"System info collection duration in seconds",
)

VISITS_LOCK = Lock()


def read_visits() -> int:
visits_path = Path(VISITS_FILE)
try:
raw_value = visits_path.read_text(encoding="utf-8").strip()
return int(raw_value) if raw_value else 0
except FileNotFoundError:
return 0
except ValueError:
logger.warning("Invalid visits counter content in %s, resetting to 0", visits_path)
return 0


def write_visits(value: int) -> int:
visits_path = Path(VISITS_FILE)
visits_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = visits_path.with_name(f"{visits_path.name}.tmp")
tmp_path.write_text(str(value), encoding="utf-8")
tmp_path.replace(visits_path)
return value


def increment_visits() -> int:
with VISITS_LOCK:
visits = read_visits() + 1
return write_visits(visits)


def iso_utc_now() -> str:
# Example desired format: 2026-01-07T14:30:00.000Z
Expand Down Expand Up @@ -136,6 +168,14 @@ def get_system_info() -> dict:
}


@app.on_event("startup")
async def initialize_visits_counter() -> None:
with VISITS_LOCK:
current_visits = read_visits()
write_visits(current_visits)
logger.info("Visits counter initialized at %d in %s", current_visits, VISITS_FILE)


@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(_: Request, exc: StarletteHTTPException):
if exc.status_code == 404:
Expand Down Expand Up @@ -202,6 +242,7 @@ async def log_requests(request: Request, call_next):
@app.get("/")
async def index(request: Request) -> dict:
ENDPOINT_CALLS.labels(endpoint="/").inc()
visits = increment_visits()
uptime = get_uptime()
with SYSTEM_INFO_DURATION_SECONDS.time():
system_info = get_system_info()
Expand All @@ -226,14 +267,25 @@ async def index(request: Request) -> dict:
"method": request.method,
"path": request.url.path,
},
"stats": {
"visits": visits,
},
"endpoints": [
{"path": "/", "method": "GET", "description": "Service information"},
{"path": "/visits", "method": "GET", "description": "Current visits count"},
{"path": "/health", "method": "GET", "description": "Health check"},
{"path": "/metrics", "method": "GET", "description": "Prometheus metrics"},
],
}


@app.get("/visits")
async def visits() -> dict:
ENDPOINT_CALLS.labels(endpoint="/visits").inc()
with VISITS_LOCK:
return {"visits": read_visits()}


@app.get("/health")
async def health() -> dict:
ENDPOINT_CALLS.labels(endpoint="/health").inc()
Expand Down
17 changes: 17 additions & 0 deletions app_python/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
version: "3.9"

services:
devops-info:
build:
context: .
dockerfile: Dockerfile
container_name: devops-info
ports:
- "5000:5000"
environment:
HOST: "0.0.0.0"
PORT: "5000"
DEBUG: "false"
VISITS_FILE: "/app/data/visits"
volumes:
- ./data:/app/data
39 changes: 38 additions & 1 deletion app_python/tests/test_app.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,41 @@
import app as app_module
import pytest
from fastapi.testclient import TestClient

from app import app

client = TestClient(app)


@pytest.fixture(autouse=True)
def isolated_visits_file(tmp_path, request):
app_module.VISITS_FILE = str(tmp_path / f"{request.node.name}.visits")


def test_visits_counter_persists_in_file(tmp_path):
visits_file = tmp_path / "visits"
app_module.VISITS_FILE = str(visits_file)

first = client.get("/")
second = client.get("/")
assert first.status_code == 200
assert second.status_code == 200

assert first.json()["stats"]["visits"] == 1
assert second.json()["stats"]["visits"] == 2
assert visits_file.read_text(encoding="utf-8").strip() == "2"


def test_visits_endpoint_reads_current_count(tmp_path):
visits_file = tmp_path / "visits"
visits_file.write_text("7", encoding="utf-8")
app_module.VISITS_FILE = str(visits_file)

response = client.get("/visits")
assert response.status_code == 200
assert response.json() == {"visits": 7}


def test_root_structure():
r = client.get("/", headers={"User-Agent": "pytest"})
assert r.status_code == 200
Expand All @@ -20,13 +52,15 @@ def test_root_structure():

assert isinstance(data["runtime"]["uptime_seconds"], int)
assert data["runtime"]["timezone"] == "UTC"
assert isinstance(data["stats"]["visits"], int)

assert data["request"]["method"] == "GET"
assert data["request"]["path"] == "/"
assert data["request"]["user_agent"] is not None

paths = {e["path"] for e in data["endpoints"]}
assert "/" in paths and "/health" in paths
assert "/" in paths and "/health" in paths and "/visits" in paths


def test_health():
r = client.get("/health")
Expand All @@ -36,6 +70,7 @@ def test_health():
assert isinstance(data["uptime_seconds"], int)
assert "timestamp" in data


def test_404_json():
r = client.get("/nope")
assert r.status_code == 404
Expand All @@ -46,6 +81,7 @@ def test_404_json():
def test_metrics_endpoint():
client.get("/")
client.get("/health")
client.get("/visits")

r = client.get("/metrics")
assert r.status_code == 200
Expand All @@ -58,3 +94,4 @@ def test_metrics_endpoint():
assert "devops_info_endpoint_calls_total" in body
assert 'endpoint="/"' in body
assert 'endpoint="/health"' in body
assert 'endpoint="/visits"' in body
Loading
Loading