diff --git a/app_python/Dockerfile b/app_python/Dockerfile index 7b96447f2e..a70562329b 100644 --- a/app_python/Dockerfile +++ b/app_python/Dockerfile @@ -1,10 +1,9 @@ FROM python:3.13-alpine -RUN addgroup -S infoservice -RUN adduser -S infoservice -RUN mkdir /app /venv -RUN chown infoservice:infoservice /venv /app +RUN adduser -D -H -h /app -u 2000 infoservice infoservice +RUN mkdir /app /venv /data +RUN chown infoservice:infoservice /venv /app /data WORKDIR /app -USER infoservice +COPY docker-entrypoint.sh / RUN python -m venv /venv ENV PATH="/venv/bin:$PATH" ENV VIRTUAL_ENV="/venv" @@ -14,5 +13,5 @@ RUN pip install -r requirements.txt COPY --chown=infoservice:infoservice app.py . EXPOSE 5000 -ENTRYPOINT ["gunicorn"] +ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["-b", "0.0.0.0:5000", "-e", "DEBUG=true", "app:app"] diff --git a/app_python/README.md b/app_python/README.md index bbd3be3d1c..342ffaa49f 100644 --- a/app_python/README.md +++ b/app_python/README.md @@ -98,7 +98,7 @@ In the next section, refer to the image as `timurusmanov/devops-infoservice`. Here is a template for the command you need to execute: ```bash -docker run <-it|-d> --name infoservice -p <127.0.0.1|0.0.0.0>::5000 +docker run <-it|-d> --name infoservice -p <127.0.0.1|0.0.0.0>::5000 -v :/data ``` - Choose `-it` if you want the program to output logs to your terminal and you want to be able to terminate the diff --git a/app_python/app.py b/app_python/app.py index 14d3caa4c0..1eb4db77ab 100644 --- a/app_python/app.py +++ b/app_python/app.py @@ -3,6 +3,7 @@ Main application module """ import json +from threading import Lock from flask import Flask, Response, jsonify, request from datetime import datetime, timezone import logging @@ -12,9 +13,30 @@ from prometheus_client import Counter, Histogram, Gauge, generate_latest +class VisitCounter: + lock: Lock + visit_count: int + FILENAME: str = '/data/visits' + + def __init__(self): + try: + with open(VisitCounter.FILENAME) as f: + self.visit_count = int(f.readline()) + except (FileNotFoundError, ValueError): + self.visit_count = 0 + self.lock = Lock() + + def inc(self) -> None: + with self.lock: + self.visit_count = self.visit_count + 1 + with open(VisitCounter.FILENAME, 'w') as f: + _ = f.write(str(self.visit_count)) + + HOST = os.getenv('HOST', '0.0.0.0') PORT = int(os.getenv('PORT', 5000)) DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' +visit_counter = VisitCounter() class PrometheusStats: @@ -128,6 +150,7 @@ def get_http_extra_info(): @prometheus.http_requests_in_progress.track_inprogress() def index(): """Main endpoint - service and system information.""" + visit_counter.inc() logger.debug(f'Request: {request.method} {request.path}', extra=get_http_extra_info()) with prometheus.system_info_duration_seconds.time(): response = { @@ -167,6 +190,11 @@ def metrics(): return Response(response=generate_latest(), status=200, content_type='text/plain') +@app.route('/visits') +def get_visits(): + return jsonify({'visits': visit_counter.visit_count}) + + @app.errorhandler(404) def notfound_handler(e): logger.info('A 404 Not Found error occured', extra=get_http_extra_info()) diff --git a/app_python/docker-entrypoint.sh b/app_python/docker-entrypoint.sh new file mode 100755 index 0000000000..d8a3e28be6 --- /dev/null +++ b/app_python/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +if [ $(whoami) = root ]; then + chown infoservice:infoservice /data + exec su infoservice -s /docker-entrypoint.sh -- "$@" +fi + +exec gunicorn "$@" diff --git a/app_python/tests/test_index.py b/app_python/tests/test_index.py index 2e57e022c1..67aded1975 100644 --- a/app_python/tests/test_index.py +++ b/app_python/tests/test_index.py @@ -1,5 +1,6 @@ import app import re +import unittest.mock """ @@ -98,6 +99,8 @@ def validate_system(sysinfo): def test_index(): + old = app.visit_counter + app.visit_counter = unittest.mock.MagicMock(app.visit_counter) app.app.testing = True with app.app.test_client() as client: for _ in range(3): @@ -112,3 +115,4 @@ def test_index(): validate_runtime(d["runtime"]) validate_service(d["service"]) validate_system(d["system"]) + app.visit_counter = old diff --git a/k8s/CONFIGMAPS.md b/k8s/CONFIGMAPS.md new file mode 100644 index 0000000000..540e652887 --- /dev/null +++ b/k8s/CONFIGMAPS.md @@ -0,0 +1,186 @@ +## Application Changes +### Description of visits counter implementation +I created a class VisitCounter, instances of which read from `/data/visits` upon creation and support incrementing. +When incrementing, the object locks a `Lock`, increments the attribute, and saves the value to `/data/visits`. + +The program creates one instance of `VisitCounter` and uses it in `GET /` (to increment) and `GET /visits` (to get the +current number of visits). + +### New endpoint documentation +`GET /visits` returns an object of the format +```json +{ + "visits": 123 +} +``` + +### Local testing evidence with Docker +I ran the new image with +```bash +docker run -it --rm -p 5000:5000 -e DEBUG=true -v ./data:/data timurusmanov/devops-infoservice:latest +``` + +Even after reloading, the number of visits does not reset: + +```bash +cat data/visits +``` +```text +5 +``` + +## ConfigMap Implementation +### ConfigMap template structure +Apart from the common fields, it just defines `data:`. + +### `config.json` content +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "dinfochart.fullname" . }}-config +data: + config.json: |- +{{ .Files.Get "files/config.json" | indent 4 }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "dinfochart.fullname" . }}-env +data: + APP_ENV: {{ .Values.environment | quote }} + LOG_LEVEL: {{ .Values.logLevel | quote }} +``` + +### How ConfigMap is mounted as file +It is done with volume mounts in `deployment.yaml`: + +```yaml +volumeMounts: + - name: app-config + mountPath: /config + readOnly: true +``` +... +```yaml +volumes: + - name: app-config + configMap: + name: {{ include "dinfochart.fullname" . }}-config +``` + +### How ConfigMap provides environment variables +In the `envFrom` section, using `configMapRef`: +```yaml +envFrom: + - configMapRef: + name: {{ include "dinfochart.fullname" . }}-env +``` + +### Verification outputs +```bash +kubectl get pods +``` +```text +NAME READY STATUS RESTARTS AGE +dinfochart-54c9dd4c9-crjtl 1/1 Running 0 10m +``` + +```text +kubectl exec dinfochart-54c9dd4c9-crjtl -it -- sh +/app # +/app # env +KUBERNETES_PORT=tcp://10.96.0.1:443 +KUBERNETES_SERVICE_PORT=443 +LOG_LEVEL= +DINFOCHART_PORT_80_TCP=tcp://10.109.215.47:80 +HOSTNAME=dinfochart-54c9dd4c9-crjtl +SHLVL=1 +HOME=/root +GPG_KEY=7169605F62C751356D054A26A821E680E5FA6305 +PYTHON_SHA256=2a84cd31dd8d8ea8aaff75de66fc1b4b0127dd5799aa50a64ae9a313885b4593 +DINFOCHART_SERVICE_HOST=10.109.215.47 +TERM=xterm +KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1 +PATH=/venv/bin:/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +KUBERNETES_PORT_443_TCP_PORT=443 +DINFOCHART_SERVICE_PORT=80 +KUBERNETES_PORT_443_TCP_PROTO=tcp +DINFOCHART_PORT=tcp://10.109.215.47:80 +PYTHON_VERSION=3.13.12 +DINFOCHART_PORT_80_TCP_ADDR=10.109.215.47 +KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443 +KUBERNETES_SERVICE_PORT_HTTPS=443 +DINFOCHART_PORT_80_TCP_PORT=80 +VIRTUAL_ENV=/venv +APP_ENV= +DINFOCHART_PORT_80_TCP_PROTO=tcp +KUBERNETES_SERVICE_HOST=10.96.0.1 +PWD=/app +/app # +/app # cat /config/config.json +{ + "appname": "devops-infoservice", + "environment": "prod", +} +``` + +## Persistent Volume +### PVC configuration explanation + + +### Access modes and storage class discussion +### Volume mount configuration +### Counter value before pod deletion +```bash +kubectl port-forward pods/dinfochart-667547c76b-n8m8j 8080:5000& +curl localhost:8080 +curl localhost:8080 +curl localhost:8080 +curl localhost:8080 +``` +```bash +curl localhost:8080/visits +``` +```text +4 +``` +### Pod deletion command +```bash +kubectl delete pod dinfochart-667547c76b-n8m8j +kubectl get pods +``` +```text +NAME READY STATUS RESTARTS AGE +dinfochart-667547c76b-p6w2c 1/1 Running 0 16s +``` +(The pod restarted) +### Counter value after new pod starts +```bash +curl localhost:8080/visits +``` +```text +4 +``` + +## ConfigMap vs Secret +### When to use ConfigMap +ConfigMap is for providing non-sensitive configuration details. + +### When to use Secret +`Secret` is for providing parameters whose value should not be seen by anybody. + +## Required Screenshots/Outputs: + +```bash +kubectl get configmap,pvc +``` +```text +NAME DATA AGE +configmap/dinfochart-config 1 30s +configmap/dinfochart-env 2 30s +configmap/kube-root-ca.crt 1 36m + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE +persistentvolumeclaim/dinfochart-data Bound pvc-11af931d-5ee4-4c4e-8364-cee981dc3183 100Mi RWO standard 30s +``` diff --git a/k8s/dinfochart/files/config.json b/k8s/dinfochart/files/config.json new file mode 100644 index 0000000000..46c93034e9 --- /dev/null +++ b/k8s/dinfochart/files/config.json @@ -0,0 +1,4 @@ +{ + "appname": "devops-infoservice", + "environment": "prod", +} diff --git a/k8s/dinfochart/templates/configmap.yaml b/k8s/dinfochart/templates/configmap.yaml new file mode 100644 index 0000000000..c78361e0a0 --- /dev/null +++ b/k8s/dinfochart/templates/configmap.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "dinfochart.fullname" . }}-config +data: + config.json: |- +{{ .Files.Get "files/config.json" | indent 4 }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "dinfochart.fullname" . }}-env +data: + APP_ENV: {{ .Values.environment | quote }} + LOG_LEVEL: {{ .Values.logLevel | quote }} diff --git a/k8s/dinfochart/templates/deployment.yaml b/k8s/dinfochart/templates/deployment.yaml index 328aa59b6e..f058aa033a 100644 --- a/k8s/dinfochart/templates/deployment.yaml +++ b/k8s/dinfochart/templates/deployment.yaml @@ -43,22 +43,19 @@ spec: readinessProbe: {{- toYaml . | nindent 12 }} {{- end }} - env: - - name: username - valueFrom: - secretKeyRef: - name: app-credentials - key: username - - name: password - valueFrom: - secretKeyRef: - name: app-credentials - key: password volumeMounts: - - name: app-secret - mountPath: "/etc/app-secret" + - name: app-config + mountPath: /config readOnly: true + - name: data-volume + mountPath: /data + envFrom: + - configMapRef: + name: {{ include "dinfochart.fullname" . }}-env volumes: - - name: app-secret - secret: - secretName: app-credentials + - name: app-config + configMap: + name: {{ include "dinfochart.fullname" . }}-config + - name: data-volume + persistentVolumeClaim: + claimName: {{ include "dinfochart.fullname" . }}-data diff --git a/k8s/dinfochart/templates/pvc.yaml b/k8s/dinfochart/templates/pvc.yaml new file mode 100644 index 0000000000..57ae29a022 --- /dev/null +++ b/k8s/dinfochart/templates/pvc.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "dinfochart.fullname" . }}-data + labels: + {{- include "dinfochart.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} diff --git a/k8s/dinfochart/values.yaml b/k8s/dinfochart/values.yaml index 329839cebd..9bf1e1b26b 100644 --- a/k8s/dinfochart/values.yaml +++ b/k8s/dinfochart/values.yaml @@ -34,3 +34,8 @@ readinessProbe: secret: username: "placeholder" password: "placeholder" + +persistence: + enabled: true + size: 100Mi + storageClass: "" # Use default diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml index 00a5f1cc63..f893cbd947 100644 --- a/monitoring/docker-compose.yml +++ b/monitoring/docker-compose.yml @@ -86,6 +86,7 @@ services: ports: - 9090:9090 volumes: + - app-data:/data:rw - ${PWD}/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - prometheus-data:/prometheus:rw command: @@ -107,3 +108,4 @@ volumes: loki-data: grafana-data: prometheus-data: + app-data: