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
29 changes: 16 additions & 13 deletions app_python/.gitignore
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
# Python
__pycache__/
*.py[cod]
venv/
*.log
.env

# IDE
.vscode/
.idea/

# OS
.DS_Store
# Python
__pycache__/
*.py[cod]
venv/
*.log
.env

# IDE
.vscode/
.idea/

# OS
.DS_Store

# Local persistence data
data/
4 changes: 3 additions & 1 deletion app_python/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ WORKDIR /app

# Create a dedicated non-root user with numeric UID/GID.
RUN addgroup --system --gid 10001 app \
&& adduser --system --uid 10001 --ingroup app --no-create-home app
&& adduser --system --uid 10001 --ingroup app --no-create-home app \
&& mkdir -p /data /config \
&& chown -R 10001:10001 /app /data /config

# Install dependencies first to leverage Docker layer caching.
COPY requirements.txt ./
Expand Down
78 changes: 61 additions & 17 deletions app_python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
[![python-ci](https://github.com/dorley174/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/dorley174/DevOps-Core-Course/actions/workflows/python-ci.yml)

## Overview
DevOps Info Service is a production-ready starter web service for the DevOps course.
It reports service metadata, runtime details, and basic system information.
DevOps Info Service is a production-ready starter web service for the DevOps course.
It reports service metadata, runtime details, basic system information, and a persisted visit counter.

The service exposes two endpoints:
- `GET /` — service + system + runtime + request information
The service exposes these endpoints:
- `GET /` — service + system + runtime + request information and increments the persisted visit counter
- `GET /visits` — returns the current visit counter without incrementing it
- `GET /health` — liveness health endpoint
- `GET /ready` — readiness health endpoint for Kubernetes
- `GET /metrics` — Prometheus metrics endpoint

## Prerequisites
- Python **3.11+**
Expand Down Expand Up @@ -39,14 +41,15 @@ python app.py

**Linux/Mac:**
```bash
HOST=127.0.0.1 PORT=8080 DEBUG=True python app.py
HOST=127.0.0.1 PORT=8080 DEBUG=True VISITS_FILE=./data/visits python app.py
```

**Windows (PowerShell):**
```powershell
$env:HOST="127.0.0.1"
$env:PORT="8080"
$env:DEBUG="True"
$env:VISITS_FILE="./data/visits"
python app.py
```

Expand All @@ -55,19 +58,29 @@ python app.py
set HOST=127.0.0.1
set PORT=8080
set DEBUG=True
set VISITS_FILE=./data/visits
python app.py
```

## API Endpoints

### `GET /`
Returns service metadata, system information, runtime details, request info, and a list of available endpoints.
Returns service metadata, system information, runtime details, request info, runtime configuration, and the current visit count.
Each request to `/` increments the persisted counter stored in `VISITS_FILE`.

Example:
```bash
curl http://127.0.0.1:5000/
```

### `GET /visits`
Returns the current counter value without incrementing it.

Example:
```bash
curl http://127.0.0.1:5000/visits
```

### `GET /health`
Returns a minimal liveness response for monitoring and Kubernetes liveness probes.

Expand All @@ -84,6 +97,37 @@ Example:
curl -i http://127.0.0.1:5000/ready
```

## Local Persistence Testing with Docker Compose

A `docker-compose.yml` file is provided to verify that the visits counter survives container restarts.

### Start the service
```bash
mkdir -p data
docker compose up --build -d
```

### Generate visits
```bash
curl http://127.0.0.1:5000/
curl http://127.0.0.1:5000/
curl http://127.0.0.1:5000/visits
cat ./data/visits
```

### Restart and verify persistence
```bash
docker compose restart
docker compose ps
curl http://127.0.0.1:5000/visits
cat ./data/visits
```

### Stop the stack
```bash
docker compose down
```

## Testing / Pretty Output

### Pretty-printed JSON
Expand Down Expand Up @@ -125,9 +169,14 @@ Add:

| Variable | Default | Description |
|----------|---------|-------------|
| HOST | 0.0.0.0 | Bind address |
| PORT | 5000 | HTTP port |
| DEBUG | False | Flask debug mode |
| HOST | `0.0.0.0` | Bind address |
| PORT | `5000` | HTTP port |
| DEBUG | `False` | Flask debug mode |
| VISITS_FILE | `/data/visits` | File used to persist the visit counter |
| APP_ENV | `dev` | Runtime environment value |
| LOG_LEVEL | `INFO` | Application log level metadata |
| CONFIG_FILE_PATH | `/config/config.json` | Mounted ConfigMap file path |
| FEATURE_VISITS_ENDPOINT | `true` | Feature flag exposed through environment variables |

---

Expand All @@ -144,25 +193,20 @@ docker build -t <image>:<tag> .
### Run

```bash
docker run --rm -p 5000:5000 <image>:<tag>
```

(Optional: override env vars)

```bash
docker run --rm -p 5000:5000 -e PORT=5000 -e DEBUG=false <image>:<tag>
docker run --rm -p 5000:5000 -e VISITS_FILE=/data/visits -v $(pwd)/data:/data <image>:<tag>
```

### Pull from Docker Hub

```bash
docker pull <dockerhub-username>/<repo>:<tag>
docker run --rm -p 5000:5000 <dockerhub-username>/<repo>:<tag>
docker run --rm -p 5000:5000 -e VISITS_FILE=/data/visits -v $(pwd)/data:/data <dockerhub-username>/<repo>:<tag>
```

### Quick test

```bash
curl http://localhost:5000/health
curl http://localhost:5000/
curl http://localhost:5000/visits
```
125 changes: 123 additions & 2 deletions app_python/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- GET / : service + system + runtime + request info
- GET /health : health check (for probes/monitoring)
- GET /metrics : Prometheus metrics endpoint
- GET /visits : current persisted visit counter value
"""

from __future__ import annotations
Expand All @@ -16,8 +17,11 @@
import platform
import socket
import sys
import tempfile
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict

from flask import Flask, Response, g, jsonify, request
Expand All @@ -39,8 +43,14 @@
SERVICE_FRAMEWORK = "Flask"
APP_VARIANT = os.getenv("APP_VARIANT", "primary")
APP_MESSAGE = os.getenv("APP_MESSAGE", "running")
APP_ENV = os.getenv("APP_ENV", "dev")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
FEATURE_VISITS_ENDPOINT = os.getenv("FEATURE_VISITS_ENDPOINT", "true")
CONFIG_FILE_PATH = os.getenv("CONFIG_FILE_PATH", "/config/config.json")
VISITS_FILE = Path(os.getenv("VISITS_FILE", "/data/visits"))

START_TIME_UTC = datetime.now(timezone.utc)
VISITS_LOCK = threading.Lock()


# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -164,6 +174,7 @@ def get_client_ip() -> str:
return request.remote_addr or "unknown"



def normalize_endpoint() -> str:
"""
Keep endpoint labels low-cardinality for Prometheus.
Expand Down Expand Up @@ -289,15 +300,102 @@ def get_system_info() -> Dict[str, Any]:



def ensure_visits_storage() -> int:
"""Create the visits file if missing and return the initial counter value."""
VISITS_FILE.parent.mkdir(parents=True, exist_ok=True)
if not VISITS_FILE.exists():
write_visits_count(0)
return 0
return read_visits_count()



def read_visits_count() -> int:
"""Read the persisted visit counter from disk."""
try:
content = VISITS_FILE.read_text(encoding="utf-8").strip()
except FileNotFoundError:
return 0

if not content:
return 0

try:
return int(content)
except ValueError:
logger.warning(
"invalid_visits_file_content",
extra={
"event": "invalid_visits_file_content",
"service": SERVICE_NAME,
"version": SERVICE_VERSION,
},
)
return 0



def write_visits_count(value: int) -> None:
"""Persist the visit counter using an atomic replace operation."""
VISITS_FILE.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=str(VISITS_FILE.parent), delete=False) as handle:
handle.write(str(value))
handle.flush()
os.fsync(handle.fileno())
temp_path = Path(handle.name)

os.replace(temp_path, VISITS_FILE)



def increment_visits_count() -> int:
"""Read, increment, and persist the visit counter with in-process locking."""
with VISITS_LOCK:
current_value = read_visits_count()
next_value = current_value + 1
write_visits_count(next_value)
return next_value



def get_runtime_config() -> Dict[str, Any]:
"""Return file-based and environment-based configuration details."""
config_payload: Dict[str, Any] = {
"app_env": APP_ENV,
"log_level": LOG_LEVEL,
"feature_visits_endpoint": FEATURE_VISITS_ENDPOINT,
"config_file_path": CONFIG_FILE_PATH,
"config_file_loaded": False,
}

config_path = Path(CONFIG_FILE_PATH)
if not config_path.exists():
config_payload["config_file_error"] = "config file not found"
return config_payload

try:
config_payload["config_file"] = json.loads(config_path.read_text(encoding="utf-8"))
config_payload["config_file_loaded"] = True
except (OSError, json.JSONDecodeError) as exc:
config_payload["config_file_error"] = str(exc)

return config_payload



def build_endpoints() -> list[Dict[str, str]]:
return [
{"path": "/", "method": "GET", "description": "Service information"},
{"path": "/", "method": "GET", "description": "Service information and visit counter increment"},
{"path": "/visits", "method": "GET", "description": "Current persisted visit count"},
{"path": "/health", "method": "GET", "description": "Liveness health check"},
{"path": "/ready", "method": "GET", "description": "Readiness health check"},
{"path": "/metrics", "method": "GET", "description": "Prometheus metrics"},
]


INITIAL_VISITS = ensure_visits_storage()


# -----------------------------------------------------------------------------
# Routes
# -----------------------------------------------------------------------------
Expand All @@ -306,6 +404,7 @@ def build_endpoints() -> list[Dict[str, str]]:
def index():
"""Main endpoint - service and system information."""
uptime = get_uptime()
current_visits = increment_visits_count()

payload: Dict[str, Any] = {
"service": {
Expand All @@ -329,12 +428,32 @@ def index():
"method": request.method,
"path": request.path,
},
"visits": {
"count": current_visits,
"file": str(VISITS_FILE),
},
"configuration": get_runtime_config(),
"endpoints": build_endpoints(),
}

return jsonify(payload), 200


@app.get("/visits")
def visits():
"""Return the current persisted visit counter without incrementing it."""
with VISITS_LOCK:
current_visits = read_visits_count()

return jsonify(
{
"visits": current_visits,
"file": str(VISITS_FILE),
"timestamp": iso_utc_now_z(),
}
), 200


@app.get("/health")
def health():
"""Health check endpoint (for probes/monitoring)."""
Expand Down Expand Up @@ -442,10 +561,12 @@ def main() -> None:
},
)
logger.info(
"runtime_configuration host=%s port=%s debug=%s",
"runtime_configuration host=%s port=%s debug=%s visits_file=%s initial_visits=%s",
HOST,
PORT,
DEBUG,
VISITS_FILE,
INITIAL_VISITS,
extra={
"event": "runtime_configuration",
"service": SERVICE_NAME,
Expand Down
Loading
Loading