diff --git a/app_python/.gitignore b/app_python/.gitignore index 061f19a9c0..4673cc485b 100644 --- a/app_python/.gitignore +++ b/app_python/.gitignore @@ -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/ diff --git a/app_python/Dockerfile b/app_python/Dockerfile index 7548f6608b..07605a7262 100644 --- a/app_python/Dockerfile +++ b/app_python/Dockerfile @@ -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 ./ diff --git a/app_python/README.md b/app_python/README.md index d418c0fd0e..26fcea3691 100644 --- a/app_python/README.md +++ b/app_python/README.md @@ -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+** @@ -39,7 +41,7 @@ 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):** @@ -47,6 +49,7 @@ HOST=127.0.0.1 PORT=8080 DEBUG=True python app.py $env:HOST="127.0.0.1" $env:PORT="8080" $env:DEBUG="True" +$env:VISITS_FILE="./data/visits" python app.py ``` @@ -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. @@ -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 @@ -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 | --- @@ -144,20 +193,14 @@ docker build -t : . ### Run ```bash -docker run --rm -p 5000:5000 : -``` - -(Optional: override env vars) - -```bash -docker run --rm -p 5000:5000 -e PORT=5000 -e DEBUG=false : +docker run --rm -p 5000:5000 -e VISITS_FILE=/data/visits -v $(pwd)/data:/data : ``` ### Pull from Docker Hub ```bash docker pull /: -docker run --rm -p 5000:5000 /: +docker run --rm -p 5000:5000 -e VISITS_FILE=/data/visits -v $(pwd)/data:/data /: ``` ### Quick test @@ -165,4 +208,5 @@ docker run --rm -p 5000:5000 /: ```bash curl http://localhost:5000/health curl http://localhost:5000/ +curl http://localhost:5000/visits ``` diff --git a/app_python/app.py b/app_python/app.py index 36e505df46..12d111a9d5 100644 --- a/app_python/app.py +++ b/app_python/app.py @@ -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 @@ -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 @@ -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() # ----------------------------------------------------------------------------- @@ -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. @@ -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 # ----------------------------------------------------------------------------- @@ -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": { @@ -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).""" @@ -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, diff --git a/app_python/docker-compose.yml b/app_python/docker-compose.yml new file mode 100644 index 0000000000..606834e083 --- /dev/null +++ b/app_python/docker-compose.yml @@ -0,0 +1,18 @@ +services: + devops-info-service: + build: + context: . + container_name: devops-info-service + ports: + - "5000:5000" + environment: + PORT: "5000" + DEBUG: "False" + VISITS_FILE: "/data/visits" + APP_ENV: "local" + LOG_LEVEL: "INFO" + CONFIG_FILE_PATH: "/config/config.json" + volumes: + - ./data:/data + restart: unless-stopped + user: "0:0" diff --git a/k8s/CONFIGMAPS.md b/k8s/CONFIGMAPS.md new file mode 100644 index 0000000000..5f8be28d05 --- /dev/null +++ b/k8s/CONFIGMAPS.md @@ -0,0 +1,531 @@ +# Lab 12 — ConfigMaps & Persistent Volumes + +This report documents the Lab 12 implementation for `devops-info-service`. + +**Bonus task was not implemented.** + +**Command outputs in this report were taken from the recorded WSL terminal sessions.** + +--- + +## 1. Application Changes + +### 1.1 Visits counter implementation +The application was upgraded to persist a visits counter in a file. + +Implemented behavior: +1. The counter file path is controlled through the `VISITS_FILE` environment variable. +2. The default path is `/data/visits`. +3. The application creates the parent directory automatically if it does not exist. +4. On startup, the service initializes the visits file with `0` when the file is missing. +5. Every request to `GET /` reads, increments, and persists the counter. +6. The `GET /visits` endpoint returns the current persisted counter value without incrementing it. + +### 1.2 Concurrency handling +A process-level `threading.Lock()` protects the read-modify-write sequence. The file is persisted with an atomic replace operation using a temporary file and `os.replace(...)`. + +This is sufficient for the lab because it prevents race conditions inside a single Flask process. + +### 1.3 New and updated endpoints +The following endpoint behavior is now implemented: + +- `GET /` — increments the persisted visits counter and returns application metadata +- `GET /visits` — returns the current visits counter from the file +- `GET /health` — liveness probe endpoint +- `GET /ready` — readiness probe endpoint +- `GET /metrics` — Prometheus metrics endpoint + +### 1.4 Local Docker Compose test setup +A new file was added: + +```text +app_python/docker-compose.yml +``` + +The compose configuration mounts a local host directory into the container: + +```yaml +volumes: + - ./data:/data +``` + +This allows the visits file to remain on the host across container restarts. + +### 1.5 Local test procedure +```bash +cd app_python +mkdir -p data +docker compose up --build -d +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 +docker compose restart +curl http://127.0.0.1:5000/visits +cat ./data/visits +``` + +```text +Local Docker Compose output was not captured in the provided terminal sessions. +The implementation was verified live in Kubernetes after the Helm deployment was fixed and the updated image was loaded into Minikube. +``` + +### 1.6 Application README update +`app_python/README.md` was updated with: +1. the new `/visits` endpoint; +2. the `VISITS_FILE` environment variable; +3. Docker Compose persistence testing steps. + +--- + +## 2. ConfigMap Implementation + +### 2.1 Helm chart files added +The main Helm chart was extended with these new files: + +```text +k8s/devops-info-service/files/config.json +k8s/devops-info-service/templates/configmap.yaml +``` + +### 2.2 File-based configuration +The chart now contains a static configuration file stored under `files/config.json`. + +Implemented JSON structure: +- application name +- environment +- description +- feature flags +- basic settings such as log level and config source + +### 2.3 ConfigMap created from file +The first ConfigMap is defined in `templates/configmap.yaml` and loads the file content through Helm `.Files.Get`. + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "devops-info-service.fullname" . }}-config +data: + config.json: |- +{{ .Files.Get "files/config.json" | indent 4 }} +``` + +### 2.4 ConfigMap created for environment variables +The same template file also defines a second ConfigMap used for environment variables. + +Injected keys: +- `APP_ENV` +- `LOG_LEVEL` +- `FEATURE_VISITS_ENDPOINT` +- `FEATURE_PERSISTENT_COUNTER` +- `CONFIG_FILE_PATH` + +### 2.5 Deployment changes for ConfigMaps +The Deployment was updated in three ways. + +#### File mount +The file-based ConfigMap is mounted as a directory: + +```yaml +volumeMounts: + - name: config-volume + mountPath: /config + readOnly: true +``` + +and the corresponding volume is: + +```yaml +volumes: + - name: config-volume + configMap: + name: {{ include "devops-info-service.fullname" . }}-config +``` + +#### Environment variable injection +The environment ConfigMap is injected with `envFrom`: + +```yaml +envFrom: + - configMapRef: + name: {{ include "devops-info-service.fullname" . }}-env +``` + +#### Application path wiring +The application reads: +- `CONFIG_FILE_PATH=/config/config.json` +- `VISITS_FILE=/data/visits` + +### 2.6 Verification commands +```bash +helm lint ./k8s/devops-info-service +helm template devops-app ./k8s/devops-info-service -n devops-lab12 -f ./k8s/devops-info-service/values-dev.yaml +helm upgrade --install devops-app ./k8s/devops-info-service \ + -n devops-lab12 \ + --create-namespace \ + -f ./k8s/devops-info-service/values-dev.yaml +kubectl get configmap -n devops-lab12 +kubectl exec -n devops-lab12 deploy/devops-app -- cat /config/config.json +kubectl exec -n devops-lab12 deploy/devops-app -- printenv | grep -E 'APP_|LOG_LEVEL|CONFIG_FILE_PATH' +``` + +```text +$ helm lint ./k8s/devops-info-service +==> Linting ./k8s/devops-info-service +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, 0 chart(s) failed + +$ helm template devops-app ./k8s/devops-info-service -n devops-lab12 -f ./k8s/devops-info-service/values-dev.yaml +Rendered resources included: +- ServiceAccount devops-app-devops-info-service +- Secret devops-app-devops-info-service-secret +- ConfigMap devops-app-devops-info-service-config +- ConfigMap devops-app-devops-info-service-env +- PersistentVolumeClaim devops-app-devops-info-service-data +- Service devops-app-devops-info-service +- Deployment devops-app-devops-info-service +- pre-install and post-install hook Jobs + +$ helm upgrade --install devops-app ./k8s/devops-info-service -n devops-lab12 --create-namespace -f ./k8s/devops-info-service/values-dev.yaml +Error: kubernetes cluster unreachable: Get "https://127.0.0.1:52858/version": dial tcp 127.0.0.1:52858: connect: connection refused + +$ minikube start +😄 minikube v1.38.1 on Ubuntu 24.04 (amd64) +✨ Using the docker driver based on existing profile +👍 Starting "minikube" primary control-plane node in "minikube" cluster +🐳 Preparing Kubernetes v1.35.1 on Docker 29.2.1 ... +🌟 Enabled addons: default-storageclass, storage-provisioner +🏄 Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default + +$ kubectl get nodes +NAME STATUS ROLES AGE VERSION +minikube Ready control-plane 20d v1.35.1 + +$ helm upgrade --install devops-app ./k8s/devops-info-service -n devops-lab12 --create-namespace -f ./k8s/devops-info-service/values-dev.yaml +Release "devops-app" does not exist. Installing it now. +Error: Service "devops-app-devops-info-service" is invalid: spec.ports[0].nodePort: Invalid value: 30080: provided port is already allocated + +$ kubectl get svc -A | grep 30080 +devops-lab09 devops-info-service NodePort 10.100.203.165 80:30080/TCP 20d + +$ helm upgrade --install devops-app ./k8s/devops-info-service -n devops-lab12 --create-namespace -f ./k8s/devops-info-service/values-dev.yaml --set service.type=ClusterIP +Release "devops-app" has been upgraded. Happy Helming! +NAME: devops-app +LAST DEPLOYED: Thu Apr 16 16:16:50 2026 +NAMESPACE: devops-lab12 +STATUS: deployed +REVISION: 3 +DESCRIPTION: Upgrade complete + +$ kubectl get all -n devops-lab12 +NAME READY STATUS RESTARTS AGE +pod/devops-app-devops-info-service-6d7c866977-dg42z 1/1 Running 0 5m13s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/devops-app-devops-info-service ClusterIP 10.102.167.101 80/TCP 15s + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/devops-app-devops-info-service 1/1 1 1 5m13s + +NAME DESIRED CURRENT READY AGE +replicaset.apps/devops-app-devops-info-service-6d7c866977 1 1 1 5m13s + +$ kubectl get configmap,pvc -n devops-lab12 +NAME DATA AGE +configmap/devops-app-devops-info-service-config 1 5m13s +configmap/devops-app-devops-info-service-env 5 5m13s +configmap/kube-root-ca.crt 1 5m19s + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE +persistentvolumeclaim/devops-app-devops-info-service-data Bound pvc-ec55758d-3d54-45cc-84d5-4ab86b2a6727 100Mi RWO standard 5m13s + +$ kubectl exec -n devops-lab12 deploy/devops-app-devops-info-service -- cat /config/config.json +{ + "application": { + "name": "devops-info-service", + "environment": "dev", + "description": "Configuration mounted from a Helm-managed ConfigMap" + }, + "features": { + "visitsEndpoint": true, + "persistentCounter": true, + "metrics": true + }, + "settings": { + "logLevel": "INFO", + "configSource": "configmap-file" + } +} + +$ kubectl exec -n devops-lab12 deploy/devops-app-devops-info-service -- printenv | grep -E 'APP_|LOG_LEVEL|CONFIG_FILE_PATH' +LOG_LEVEL=DEBUG +APP_MESSAGE=Lab 12 Helm development deployment +APP_ENV=dev +CONFIG_FILE_PATH=/config/config.json +APP_VARIANT=app1 +APP_USERNAME=change-me-username +APP_PASSWORD=change-me-password +``` + +### 2.7 Expected verification evidence +Required evidence for the report: +1. `kubectl get configmap,pvc` +2. `cat /config/config.json` from inside the pod +3. environment variables printed from inside the pod + +--- + +## 3. Persistent Volume Implementation + +### 3.1 PersistentVolumeClaim template +A new template was added: + +```text +k8s/devops-info-service/templates/pvc.yaml +``` + +Implemented PVC characteristics: +- `ReadWriteOnce` access mode +- configurable storage request +- configurable storage class +- default requested size: `100Mi` + +### 3.2 Values added for persistence +The chart now includes this values block: + +```yaml +persistence: + enabled: true + mountPath: "/data" + size: 100Mi + storageClass: "" +``` + +An empty storage class means the cluster default storage class is used. + +### 3.3 Deployment changes for persistent storage +The application pod now mounts persistent storage under `/data`. + +```yaml +volumeMounts: + - name: data-volume + mountPath: /data +``` + +The volume references the PVC when persistence is enabled. + +```yaml +volumes: + - name: data-volume + persistentVolumeClaim: + claimName: {{ include "devops-info-service.fullname" . }}-data +``` + +### 3.4 Security context adjustment +Because the container runs as a non-root user, the pod security context was extended with: + +```yaml +securityContext: + seccompProfile: + type: RuntimeDefault + fsGroup: 10001 +``` + +This helps ensure that the mounted volume is writable by the application process. + +### 3.5 Persistence verification procedure +```bash +kubectl get pvc -n devops-lab12 +kubectl get pods -n devops-lab12 +curl http://127.0.0.1:8080/ +curl http://127.0.0.1:8080/ +curl http://127.0.0.1:8080/visits +kubectl exec -n devops-lab12 -- cat /data/visits +kubectl delete pod -n devops-lab12 +kubectl get pods -n devops-lab12 -w +curl http://127.0.0.1:8080/visits +kubectl exec -n devops-lab12 -- cat /data/visits +``` + +```text +$ kubectl port-forward -n devops-lab12 service/devops-app-devops-info-service 8080:80 +Forwarding from 127.0.0.1:8080 -> 5000 +Forwarding from [::1]:8080 -> 5000 +Handling connection for 8080 +Handling connection for 8080 +Handling connection for 8080 + +$ curl http://127.0.0.1:8080/ +{"endpoints":[{"description":"Service information","method":"GET","path":"/"},{"description":"Liveness health check","method":"GET","path":"/health"},{"description":"Readiness health check","method":"GET","path":"/ready"},{"description":"Prometheus metrics","method":"GET","path":"/metrics"}],"request":{"client_ip":"127.0.0.1","method":"GET","path":"/","user_agent":"curl/8.5.0"},"runtime":{"current_time":"2026-04-16T13:17:24.097Z","timezone":"UTC","uptime_human":"0 hours, 5 minutes","uptime_seconds":315},"service":{"description":"DevOps course info service","framework":"Flask","message":"Lab 12 Helm development deployment","name":"devops-info-service","variant":"app1","version":"lab12-dev"},"system":{"architecture":"x86_64","cpu_count":20,"hostname":"devops-app-devops-info-service-6d7c866977-dg42z","platform":"Linux","platform_version":"Linux-5.15.153.1-microsoft-standard-WSL2-x86_64-with-glibc2.36","python_version":"3.13.1"}} + +$ curl http://127.0.0.1:8080/visits +{"error":"Not Found","message":"Endpoint does not exist","timestamp":"2026-04-16T13:17:24.210Z"} + +$ kubectl exec -n devops-lab12 deploy/devops-app-devops-info-service -- cat /data/visits +cat: /data/visits: No such file or directory +command terminated with exit code 1 + +The first deployment was using an older image, so the /visits endpoint and persisted counter were not available yet. + +$ minikube image build -t devops-info-service:lab12 ./app_python +#0 building with "default" instance using docker driver +#11 naming to docker.io/library/devops-info-service:lab12 done +#11 DONE 0.1s + +$ helm upgrade --install devops-app ./k8s/devops-info-service -n devops-lab12 --create-namespace -f ./k8s/devops-info-service/values-dev.yaml --set service.type=ClusterIP --set image.repository=devops-info-service --set image.tag=lab12 --set image.pullPolicy=IfNotPresent +Release "devops-app" has been upgraded. Happy Helming! +NAME: devops-app +LAST DEPLOYED: Thu Apr 16 16:23:14 2026 +NAMESPACE: devops-lab12 +STATUS: deployed +REVISION: 4 +DESCRIPTION: Upgrade complete + +$ kubectl rollout status deployment/devops-app-devops-info-service -n devops-lab12 +Waiting for deployment "devops-app-devops-info-service" rollout to finish: 1 old replicas are pending termination... +Waiting for deployment "devops-app-devops-info-service" rollout to finish: 1 old replicas are pending termination... +deployment "devops-app-devops-info-service" successfully rolled out + +$ kubectl get pod -n devops-lab12 -o jsonpath='{.items[0].spec.containers[0].image}'; echo +devops-info-service:lab12 + +$ kubectl port-forward -n devops-lab12 service/devops-app-devops-info-service 8080:80 +Forwarding from 127.0.0.1:8080 -> 5000 +Forwarding from [::1]:8080 -> 5000 +Handling connection for 8080 +Handling connection for 8080 + +$ curl http://127.0.0.1:8080/ +{"configuration":{"app_env":"dev","config_file":{"application":{"description":"Configuration mounted from a Helm-managed ConfigMap","environment":"dev","name":"devops-info-service"},"features":{"metrics":true,"persistentCounter":true,"visitsEndpoint":true},"settings":{"configSource":"configmap-file","logLevel":"INFO"}},"config_file_loaded":true,"config_file_path":"/config/config.json","feature_visits_endpoint":"true","log_level":"DEBUG"},"endpoints":[{"description":"Service information and visit counter increment","method":"GET","path":"/"},{"description":"Current persisted visit count","method":"GET","path":"/visits"},{"description":"Liveness health check","method":"GET","path":"/health"},{"description":"Readiness health check","method":"GET","path":"/ready"},{"description":"Prometheus metrics","method":"GET","path":"/metrics"}],"request":{"client_ip":"127.0.0.1","method":"GET","path":"/","user_agent":"curl/8.5.0"},"runtime":{"current_time":"2026-04-16T13:24:11.246Z","timezone":"UTC","uptime_human":"0 hours, 0 minutes","uptime_seconds":43},"service":{"description":"DevOps course info service","framework":"Flask","message":"Lab 12 Helm development deployment","name":"devops-info-service","variant":"app1","version":"lab12-dev"},"system":{"architecture":"x86_64","cpu_count":20,"hostname":"devops-app-devops-info-service-5cb8488cbb-cfm8v","platform":"Linux","platform_version":"Linux-5.15.153.1-microsoft-standard-WSL2-x86_64-with-glibc2.36","python_version":"3.13.1"},"visits":{"count":1,"file":"/data/visits"}} + +$ curl http://127.0.0.1:8080/ +{"configuration":{"app_env":"dev","config_file":{"application":{"description":"Configuration mounted from a Helm-managed ConfigMap","environment":"dev","name":"devops-info-service"},"features":{"metrics":true,"persistentCounter":true,"visitsEndpoint":true},"settings":{"configSource":"configmap-file","logLevel":"INFO"}},"config_file_loaded":true,"config_file_path":"/config/config.json","feature_visits_endpoint":"true","log_level":"DEBUG"},"endpoints":[{"description":"Service information and visit counter increment","method":"GET","path":"/"},{"description":"Current persisted visit count","method":"GET","path":"/visits"},{"description":"Liveness health check","method":"GET","path":"/health"},{"description":"Readiness health check","method":"GET","path":"/ready"},{"description":"Prometheus metrics","method":"GET","path":"/metrics"}],"request":{"client_ip":"127.0.0.1","method":"GET","path":"/","user_agent":"curl/8.5.0"},"runtime":{"current_time":"2026-04-16T13:24:11.354Z","timezone":"UTC","uptime_human":"0 hours, 0 minutes","uptime_seconds":43},"service":{"description":"DevOps course info service","framework":"Flask","message":"Lab 12 Helm development deployment","name":"devops-info-service","variant":"app1","version":"lab12-dev"},"system":{"architecture":"x86_64","cpu_count":20,"hostname":"devops-app-devops-info-service-5cb8488cbb-cfm8v","platform":"Linux","platform_version":"Linux-5.15.153.1-microsoft-standard-WSL2-x86_64-with-glibc2.36","python_version":"3.13.1"},"visits":{"count":2,"file":"/data/visits"}} + +$ curl http://127.0.0.1:8080/visits +{"file":"/data/visits","timestamp":"2026-04-16T13:24:11.370Z","visits":2} + +$ kubectl exec -n devops-lab12 deploy/devops-app-devops-info-service -- cat /data/visits +2 + +$ kubectl delete pod -n devops-lab12 $(kubectl get pod -n devops-lab12 -o name | head -n 1 | cut -d/ -f2) +pod "devops-app-devops-info-service-5cb8488cbb-cfm8v" deleted from devops-lab12 namespace + +$ kubectl rollout status deployment/devops-app-devops-info-service -n devops-lab12 +deployment "devops-app-devops-info-service" successfully rolled out + +$ kubectl get pods -n devops-lab12 -w +NAME READY STATUS RESTARTS AGE +devops-app-devops-info-service-5cb8488cbb-bf5cn 1/1 Running 0 4m19s + +$ kubectl port-forward -n devops-lab12 service/devops-app-devops-info-service 8081:80 +Forwarding from 127.0.0.1:8081 -> 5000 +Forwarding from [::1]:8081 -> 5000 +Handling connection for 8081 +Handling connection for 8081 + +$ curl http://127.0.0.1:8081/visits +{"file":"/data/visits","timestamp":"2026-04-16T13:29:26.440Z","visits":2} + +$ kubectl exec -n devops-lab12 deploy/devops-app-devops-info-service -- cat /data/visits +2 +``` + +### 3.6 Persistence result interpretation +The expected correct result is: +1. the visits counter increases after requests to `/`; +2. the value stored in `/data/visits` matches the `/visits` endpoint; +3. after deleting the pod, the Deployment creates a replacement pod; +4. the replacement pod reads the same persisted value from the PVC. + +--- + +## 4. ConfigMap vs Secret + +### 4.1 When to use ConfigMap +Use a ConfigMap for non-sensitive configuration such as: +- application environment names; +- feature flags; +- log levels; +- JSON or text configuration files; +- non-secret runtime parameters. + +### 4.2 When to use Secret +Use a Secret for sensitive data such as: +- passwords; +- API tokens; +- private keys; +- database credentials; +- any confidential application value. + +### 4.3 Key differences +| Aspect | ConfigMap | Secret | +|---|---|---| +| Intended data type | Non-sensitive configuration | Sensitive values | +| Typical examples | Log level, app mode, feature flags | Passwords, tokens, credentials | +| Encoding | Plain manifest data | Base64-encoded object data | +| Security expectation | Convenience and separation of config | Restricted access and stronger protection | +| Typical consumption | Mounted files or environment variables | Mounted files or environment variables | + +### 4.4 Practical rule used in this lab +In this repository: +- ConfigMaps are used for `config.json` and general runtime variables. +- Kubernetes Secrets remain responsible for credentials such as `APP_USERNAME` and `APP_PASSWORD`. + +--- + +## 5. Final File Summary + +### 5.1 Application files changed +```text +app_python/app.py +app_python/Dockerfile +app_python/docker-compose.yml +app_python/README.md +app_python/.gitignore +``` + +### 5.2 Helm files changed or added +```text +k8s/devops-info-service/Chart.yaml +k8s/devops-info-service/values.yaml +k8s/devops-info-service/values-dev.yaml +k8s/devops-info-service/values-prod.yaml +k8s/devops-info-service/files/config.json +k8s/devops-info-service/templates/configmap.yaml +k8s/devops-info-service/templates/deployment.yaml +k8s/devops-info-service/templates/pvc.yaml +k8s/CONFIGMAPS.md +``` + +--- + +## 6. Submission Checklist Mapping + +### Task 1 — Application Persistence Upgrade +- [x] Visits counter implemented +- [x] `/visits` endpoint created +- [x] Counter persists in file +- [x] Docker Compose volume configured +- [x] Application README updated + +### Task 2 — ConfigMaps +- [x] `files/config.json` created +- [x] ConfigMap template for file mounting created +- [x] ConfigMap template for environment variables created +- [x] Deployment mounts ConfigMap as file +- [x] Deployment injects environment variables with `envFrom` + +### Task 3 — Persistent Volumes +- [x] PVC template created +- [x] PVC mounted to Deployment +- [x] Visits file stored under `/data/visits` +- [x] Persistence verification procedure documented + +### Task 4 — Documentation +- [x] `k8s/CONFIGMAPS.md` created +- [x] Application changes documented +- [x] ConfigMap implementation documented +- [x] Persistent volume implementation documented +- [x] Verification sections completed with recorded terminal outputs diff --git a/k8s/devops-info-service/Chart.yaml b/k8s/devops-info-service/Chart.yaml index 8d679cf8d4..396a45c829 100644 --- a/k8s/devops-info-service/Chart.yaml +++ b/k8s/devops-info-service/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 name: devops-info-service -description: Helm chart for the DevOps info service with Kubernetes Secrets and Vault integration +description: Helm chart for the DevOps info service with Kubernetes Secrets, ConfigMaps, and persistent storage type: application -version: 0.2.0 +version: 0.3.0 appVersion: "latest" keywords: - python @@ -10,6 +10,8 @@ keywords: - kubernetes - helm - secrets + - configmap + - pvc - vault dependencies: - name: common-lib diff --git a/k8s/devops-info-service/files/config.json b/k8s/devops-info-service/files/config.json new file mode 100644 index 0000000000..1081ad50c0 --- /dev/null +++ b/k8s/devops-info-service/files/config.json @@ -0,0 +1,16 @@ +{ + "application": { + "name": "devops-info-service", + "environment": "dev", + "description": "Configuration mounted from a Helm-managed ConfigMap" + }, + "features": { + "visitsEndpoint": true, + "persistentCounter": true, + "metrics": true + }, + "settings": { + "logLevel": "INFO", + "configSource": "configmap-file" + } +} diff --git a/k8s/devops-info-service/templates/configmap.yaml b/k8s/devops-info-service/templates/configmap.yaml new file mode 100644 index 0000000000..f7b4f51d46 --- /dev/null +++ b/k8s/devops-info-service/templates/configmap.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "devops-info-service.fullname" . }}-config + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +data: + config.json: |- +{{ .Files.Get "files/config.json" | indent 4 }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "devops-info-service.fullname" . }}-env + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +data: + APP_ENV: {{ .Values.config.environment | quote }} + LOG_LEVEL: {{ .Values.config.logLevel | quote }} + FEATURE_VISITS_ENDPOINT: {{ ternary "true" "false" .Values.config.featureFlags.visitsEndpoint | quote }} + FEATURE_PERSISTENT_COUNTER: {{ ternary "true" "false" .Values.config.featureFlags.persistentCounter | quote }} + CONFIG_FILE_PATH: {{ printf "%s/%s" .Values.config.mountPath .Values.config.fileName | quote }} diff --git a/k8s/devops-info-service/templates/deployment.yaml b/k8s/devops-info-service/templates/deployment.yaml index 8a470572e4..8f4c1b9bae 100644 --- a/k8s/devops-info-service/templates/deployment.yaml +++ b/k8s/devops-info-service/templates/deployment.yaml @@ -42,6 +42,7 @@ spec: securityContext: seccompProfile: type: RuntimeDefault + fsGroup: 10001 containers: - name: {{ .Chart.Name }} image: "{{ required "image.repository is required" .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" @@ -61,6 +62,8 @@ spec: value: {{ .Values.env.appMessage | quote }} - name: SERVICE_VERSION value: {{ .Values.env.serviceVersion | quote }} + - name: VISITS_FILE + value: {{ .Values.env.visitsFile | quote }} {{- if .Values.secrets.enabled }} - name: APP_USERNAME valueFrom: @@ -73,6 +76,15 @@ spec: name: {{ include "devops-info-service.secretName" . }} key: password {{- end }} + envFrom: + - configMapRef: + name: {{ include "devops-info-service.fullname" . }}-env + volumeMounts: + - name: config-volume + mountPath: {{ .Values.config.mountPath }} + readOnly: true + - name: data-volume + mountPath: {{ .Values.persistence.mountPath }} resources: {{- toYaml .Values.resources | nindent 12 }} readinessProbe: @@ -97,3 +109,16 @@ spec: drop: - ALL runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + volumes: + - name: config-volume + configMap: + name: {{ include "devops-info-service.fullname" . }}-config + - name: data-volume + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "devops-info-service.fullname" . }}-data + {{- else }} + emptyDir: {} + {{- end }} diff --git a/k8s/devops-info-service/templates/pvc.yaml b/k8s/devops-info-service/templates/pvc.yaml new file mode 100644 index 0000000000..862717f000 --- /dev/null +++ b/k8s/devops-info-service/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "devops-info-service.fullname" . }}-data + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} +{{- end }} diff --git a/k8s/devops-info-service/values-dev.yaml b/k8s/devops-info-service/values-dev.yaml index 12f9bdcc5e..33e0223354 100644 --- a/k8s/devops-info-service/values-dev.yaml +++ b/k8s/devops-info-service/values-dev.yaml @@ -15,8 +15,12 @@ resources: memory: 128Mi env: - appMessage: "Lab 11 Helm development deployment" - serviceVersion: "lab11-dev" + appMessage: "Lab 12 Helm development deployment" + serviceVersion: "lab12-dev" + +config: + environment: "dev" + logLevel: "DEBUG" readinessProbe: initialDelaySeconds: 3 diff --git a/k8s/devops-info-service/values-prod.yaml b/k8s/devops-info-service/values-prod.yaml index 31e0dcc16e..7979c66ece 100644 --- a/k8s/devops-info-service/values-prod.yaml +++ b/k8s/devops-info-service/values-prod.yaml @@ -15,8 +15,12 @@ resources: memory: 512Mi env: - appMessage: "Lab 11 Helm production deployment" - serviceVersion: "lab11-prod" + appMessage: "Lab 12 Helm production deployment" + serviceVersion: "lab12-prod" + +config: + environment: "prod" + logLevel: "INFO" readinessProbe: initialDelaySeconds: 10 diff --git a/k8s/devops-info-service/values.yaml b/k8s/devops-info-service/values.yaml index a072770ca1..db89b8b36e 100644 --- a/k8s/devops-info-service/values.yaml +++ b/k8s/devops-info-service/values.yaml @@ -42,8 +42,24 @@ env: port: "5000" debug: "False" appVariant: app1 - appMessage: "Lab 11 secret-managed deployment" - serviceVersion: "lab11-v1" + appMessage: "Lab 12 configmap and persistence deployment" + serviceVersion: "lab12-v1" + visitsFile: "/data/visits" + +config: + mountPath: "/config" + fileName: "config.json" + environment: "dev" + logLevel: "INFO" + featureFlags: + visitsEndpoint: true + persistentCounter: true + +persistence: + enabled: true + mountPath: "/data" + size: 100Mi + storageClass: "" secrets: enabled: true