From 8e140c5d9986d0c006b05e27d40e8811dd62cdde Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Thu, 26 Mar 2026 23:40:23 +0300
Subject: [PATCH 01/11] k8s
---
k8s/README.md | 38 ++++++++++++++++++++++
k8s/deployment.yml | 59 +++++++++++++++++++++++++++++++++++
k8s/service.yml | 13 ++++++++
monitoring/docker-compose.yml | 2 +-
4 files changed, 111 insertions(+), 1 deletion(-)
create mode 100644 k8s/README.md
create mode 100644 k8s/deployment.yml
create mode 100644 k8s/service.yml
diff --git a/k8s/README.md b/k8s/README.md
new file mode 100644
index 0000000000..c016b05e06
--- /dev/null
+++ b/k8s/README.md
@@ -0,0 +1,38 @@
+# Kubernetes Lab 9
+
+## Task 1 - Local Kubernetes Setup
+
+I used `minikube` because it was in Arch Linux extra repo (`kind` is only in AUR), integrates cleanly with the Docker driver, and has more features.
+
+
+Cluster setup verification output
+
+```text
+$ minikube status
+minikube
+type: Control Plane
+host: Running
+kubelet: Running
+apiserver: Running
+kubeconfig: Configured
+
+
+$ kubectl cluster-info
+Kubernetes control plane is running at https://192.168.49.2:8443
+CoreDNS is running at https://192.168.49.2:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
+
+To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.
+
+$ kubectl get nodes -o wide
+NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
+minikube Ready control-plane 2m45s v1.35.1 192.168.49.2 Debian GNU/Linux 12 (bookworm) 6.19.10-1-cachyos docker://29.2.1
+
+$ kubectl get namespaces
+NAME STATUS AGE
+default Active 3m9s
+kube-node-lease Active 3m9s
+kube-public Active 3m9s
+kube-system Active 3m9s
+```
+
+
diff --git a/k8s/deployment.yml b/k8s/deployment.yml
new file mode 100644
index 0000000000..a65e8712d1
--- /dev/null
+++ b/k8s/deployment.yml
@@ -0,0 +1,59 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: devops-app-py
+ labels:
+ app.kubernetes.io/name: devops-app-py
+ app.kubernetes.io/part-of: devops-core-s26
+spec:
+ replicas: 3
+ revisionHistoryLimit: 5
+ strategy:
+ type: RollingUpdate
+ rollingUpdate:
+ maxSurge: 1
+ maxUnavailable: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: devops-app-py
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: devops-app-py
+ app.kubernetes.io/part-of: devops-core-s26
+ spec:
+ containers:
+ - name: devops-app-py
+ image: localt0aster/devops-app-py:1.8
+ imagePullPolicy: IfNotPresent
+ ports:
+ - name: http
+ containerPort: 5000
+ env:
+ - name: HOST
+ value: "0.0.0.0"
+ - name: PORT
+ value: "5000"
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: http
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ timeoutSeconds: 2
+ failureThreshold: 3
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: http
+ initialDelaySeconds: 5
+ periodSeconds: 5
+ timeoutSeconds: 2
+ failureThreshold: 3
+ resources:
+ requests:
+ cpu: 100m
+ memory: 128Mi
+ limits:
+ cpu: 250m
+ memory: 256Mi
diff --git a/k8s/service.yml b/k8s/service.yml
new file mode 100644
index 0000000000..4b8e7275f6
--- /dev/null
+++ b/k8s/service.yml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: your-app-service
+spec:
+ type: NodePort
+ selector:
+ app: your-app # Must match Deployment labels
+ ports:
+ - protocol: TCP
+ port: 80 # Service port
+ targetPort: 8000 # Container port
+ nodePort: 30080 # Optional: specific node port (30000-32767)
diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml
index 8b155f0c50..17e5da4e4d 100644
--- a/monitoring/docker-compose.yml
+++ b/monitoring/docker-compose.yml
@@ -133,7 +133,7 @@ services:
restart: unless-stopped
app-python:
- image: localt0aster/devops-app-py:1.8.806c77e
+ image: localt0aster/devops-app-py:1.8
environment:
HOST: "0.0.0.0"
PORT: "8000"
From 50cbe9d4eb391fbd75403bf41732f45068ccd1d2 Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Fri, 27 Mar 2026 04:33:42 +0300
Subject: [PATCH 02/11] add: docker build dev tag to not look for commit hashes
---
.github/workflows/go-docker.yml | 3 +++
.github/workflows/python-docker.yml | 3 +++
2 files changed, 6 insertions(+)
diff --git a/.github/workflows/go-docker.yml b/.github/workflows/go-docker.yml
index a1fcc9268f..2fc2d8121e 100644
--- a/.github/workflows/go-docker.yml
+++ b/.github/workflows/go-docker.yml
@@ -31,6 +31,7 @@ jobs:
lab_number=$((10#$lab_number))
short_sha="${GITHUB_SHA::7}"
echo "branch_tag=1.${lab_number}.${short_sha}" >> "$GITHUB_OUTPUT"
+ echo "branch_dev_tag=1.${lab_number}-dev" >> "$GITHUB_OUTPUT"
else
echo "Failed to extract lab number from branch: $source_branch" >&2
exit 1
@@ -48,6 +49,8 @@ jobs:
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/devops-app-go:${{ steps.version.outputs.branch_tag }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-go:${{ steps.version.outputs.branch_dev_tag }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-go:dev
build-and-push:
if: github.event.pull_request.merged == true
diff --git a/.github/workflows/python-docker.yml b/.github/workflows/python-docker.yml
index dc486928df..047be1e6c7 100644
--- a/.github/workflows/python-docker.yml
+++ b/.github/workflows/python-docker.yml
@@ -31,6 +31,7 @@ jobs:
lab_number=$((10#$lab_number))
short_sha="${GITHUB_SHA::7}"
echo "branch_tag=1.${lab_number}.${short_sha}" >> "$GITHUB_OUTPUT"
+ echo "branch_dev_tag=1.${lab_number}-dev" >> "$GITHUB_OUTPUT"
else
echo "Failed to extract lab number from branch: $source_branch" >&2
exit 1
@@ -48,6 +49,8 @@ jobs:
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/devops-app-py:${{ steps.version.outputs.branch_tag }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-py:${{ steps.version.outputs.branch_dev_tag }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-py:dev
build-and-push:
if: github.event.pull_request.merged == true
From a6e2138bbddf5a0f2aa22c9c0753e87a5b334818 Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Fri, 27 Mar 2026 04:41:55 +0300
Subject: [PATCH 03/11] feat: app_py `/ready` endpoint
---
app_python/README.md | 3 ++-
app_python/src/router.py | 14 +++++++++++++-
app_python/tests/test_endpoints.py | 31 ++++++++++++++++++++++++++++++
app_python/tests/test_metrics.py | 7 +++++++
4 files changed, 53 insertions(+), 2 deletions(-)
diff --git a/app_python/README.md b/app_python/README.md
index dd63b7a64a..78a0b12cc8 100644
--- a/app_python/README.md
+++ b/app_python/README.md
@@ -4,7 +4,7 @@
## Overview
-Small Flask web service that reports service metadata, system information, runtime uptime, and basic request details. Includes health and Prometheus metrics endpoints for monitoring.
+Small Flask web service that reports service metadata, system information, runtime uptime, and basic request details. Includes health, readiness, and Prometheus metrics endpoints for monitoring.
## Prerequisites
@@ -54,6 +54,7 @@ Gunicorn access logs are emitted as JSON so Loki can parse request fields cleanl
- `GET /` - Service and system information
- `GET /health` - Health check
+- `GET /ready` - Readiness check
- `GET /metrics` - Prometheus metrics exposition
## Configuration
diff --git a/app_python/src/router.py b/app_python/src/router.py
index 294be6246d..bd9315d8b5 100644
--- a/app_python/src/router.py
+++ b/app_python/src/router.py
@@ -147,9 +147,21 @@ def index():
def health():
"""Health check."""
record_endpoint_call("/health")
+ return _status_response("healthy")
+
+
+@app.route("/ready")
+def readiness():
+ """Readiness check."""
+ record_endpoint_call("/ready")
+ return _status_response("ready")
+
+
+def _status_response(status: str):
+ """Return a shared JSON payload for health-style endpoints."""
return jsonify(
{
- "status": "healthy",
+ "status": status,
"timestamp": datetime.now(timezone.utc).isoformat(),
"uptime_seconds": get_uptime()["seconds"],
}
diff --git a/app_python/tests/test_endpoints.py b/app_python/tests/test_endpoints.py
index 97c217b476..6299a7c1dd 100644
--- a/app_python/tests/test_endpoints.py
+++ b/app_python/tests/test_endpoints.py
@@ -62,6 +62,7 @@ def test_index_returns_expected_json_structure_and_types(client):
route_index = {(endpoint["method"], endpoint["path"]) for endpoint in endpoints}
assert ("GET", "/") in route_index
assert ("GET", "/health") in route_index
+ assert ("GET", "/ready") in route_index
assert ("GET", "/metrics") in route_index
@@ -82,6 +83,23 @@ def test_health_returns_expected_json_structure_and_types(client):
assert parsed_timestamp.tzinfo is not None
+def test_ready_returns_expected_json_structure_and_types(client):
+ """GET /ready should report ready status and typed runtime metadata."""
+ response = client.get("/ready")
+
+ assert response.status_code == 200
+ payload = response.get_json()
+ assert payload is not None
+
+ assert {"status", "timestamp", "uptime_seconds"} <= payload.keys()
+ assert payload["status"] == "ready"
+ assert isinstance(payload["uptime_seconds"], int)
+ assert payload["uptime_seconds"] >= 0
+
+ parsed_timestamp = datetime.fromisoformat(payload["timestamp"])
+ assert parsed_timestamp.tzinfo is not None
+
+
def test_unknown_endpoint_returns_json_404(client):
"""Unknown routes should be handled by JSON 404 error handler."""
response = client.get("/definitely-does-not-exist")
@@ -117,3 +135,16 @@ def test_health_returns_json_500_when_uptime_probe_fails(client, monkeypatch):
"error": "Internal Server Error",
"message": "An unexpected error occurred",
}
+
+
+def test_ready_returns_json_500_when_uptime_probe_fails(client, monkeypatch):
+ """GET /ready should return JSON 500 when uptime collection crashes."""
+ monkeypatch.setattr(router, "get_uptime", _raise_runtime_error)
+
+ response = client.get("/ready")
+
+ assert response.status_code == 500
+ assert response.get_json() == {
+ "error": "Internal Server Error",
+ "message": "An unexpected error occurred",
+ }
diff --git a/app_python/tests/test_metrics.py b/app_python/tests/test_metrics.py
index ac7da3ad0b..717f9320dc 100644
--- a/app_python/tests/test_metrics.py
+++ b/app_python/tests/test_metrics.py
@@ -40,6 +40,7 @@ def test_metrics_endpoint_exposes_http_and_application_metrics(client):
"""Metrics endpoint should expose HTTP RED data and app-specific metrics."""
client.get("/")
client.get("/health")
+ client.get("/ready")
client.get("/does-not-exist")
response = client.get("/metrics")
@@ -58,6 +59,11 @@ def test_metrics_endpoint_exposes_http_and_application_metrics(client):
"http_requests_total",
{"method": "GET", "endpoint": "/health", "status_code": "200"},
)
+ ready_total = _metric_value(
+ metrics_text,
+ "http_requests_total",
+ {"method": "GET", "endpoint": "/ready", "status_code": "200"},
+ )
unmatched_total = _metric_value(
metrics_text,
"http_requests_total",
@@ -85,6 +91,7 @@ def test_metrics_endpoint_exposes_http_and_application_metrics(client):
assert root_total is not None and root_total >= 1.0
assert health_total is not None and health_total >= 1.0
+ assert ready_total is not None and ready_total >= 1.0
assert unmatched_total is not None and unmatched_total >= 1.0
assert root_duration_count is not None and root_duration_count >= 1.0
assert root_in_progress == 0.0
From 8f4ddf56df8d70df04e912115eb3c2a06e170dce Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Fri, 27 Mar 2026 04:43:03 +0300
Subject: [PATCH 04/11] feat: app_go feature pairity with app_py
---
app_go/Dockerfile | 7 +-
app_go/README.md | 4 +-
app_go/go.mod | 30 +++-
app_go/go.sum | 62 ++++++++
app_go/main.go | 136 +++++++++++++++++-
app_go/main_test.go | 339 +++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 559 insertions(+), 19 deletions(-)
create mode 100644 app_go/go.sum
diff --git a/app_go/Dockerfile b/app_go/Dockerfile
index aa269d06e2..09e812af80 100644
--- a/app_go/Dockerfile
+++ b/app_go/Dockerfile
@@ -1,9 +1,8 @@
FROM golang:1.25-alpine AS build
WORKDIR /app
-# Uncomment for dependency installation
-# COPY go.mod go.sum ./
-# RUN go mod download
-COPY go.mod *.go ./
+COPY go.mod go.sum ./
+RUN go mod download
+COPY *.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -o devops-info-service.out
FROM scratch
diff --git a/app_go/README.md b/app_go/README.md
index 22a27fa787..1e8a5d867a 100644
--- a/app_go/README.md
+++ b/app_go/README.md
@@ -1,7 +1,7 @@
# DevOps Info Service (Go)
## Overview
-Simple Go web service that exposes system/runtime details and a health check.
+Simple Go web service that exposes system/runtime details, health and readiness checks, Prometheus metrics, and structured JSON logs.
## Prerequisites
- Go 1.25+
@@ -21,6 +21,8 @@ HOST=127.0.0.1 PORT=8080 ./devops-info-service.out
## Endpoints
- `GET /` - service + system + runtime + request info
- `GET /health` - health check
+- `GET /ready` - readiness check
+- `GET /metrics` - Prometheus metrics exposition
## Configuration
diff --git a/app_go/go.mod b/app_go/go.mod
index ed5d7b4f3e..557f282ff5 100644
--- a/app_go/go.mod
+++ b/app_go/go.mod
@@ -1,3 +1,31 @@
module example.com/devops-info-service
-go 1.25
+go 1.25.0
+
+require github.com/prometheus/client_golang v1.23.2
+
+require (
+ cloud.google.com/go/compute/metadata v0.9.0 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/creack/pty v1.1.24 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+ github.com/golang/protobuf v1.5.4 // indirect
+ github.com/klauspost/compress v1.18.5 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.67.5 // indirect
+ github.com/prometheus/procfs v0.20.1 // indirect
+ github.com/rogpeppe/go-internal v1.14.1 // indirect
+ github.com/yuin/goldmark v1.8.2 // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
+ golang.org/x/mod v0.34.0 // indirect
+ golang.org/x/net v0.52.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.42.0 // indirect
+ golang.org/x/telemetry v0.0.0-20260316223853-b6b0c46d1ccd // indirect
+ golang.org/x/text v0.35.0 // indirect
+ golang.org/x/tools v0.43.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+)
diff --git a/app_go/go.sum b/app_go/go.sum
new file mode 100644
index 0000000000..3e3948f37d
--- /dev/null
+++ b/app_go/go.sum
@@ -0,0 +1,62 @@
+cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
+cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
+github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
+github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
+github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
+github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
+github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/telemetry v0.0.0-20260316223853-b6b0c46d1ccd h1:QbR6Giw8AyR6v6Vff72jiZRUdZnetfgYRndQuKa806k=
+golang.org/x/telemetry v0.0.0-20260316223853-b6b0c46d1ccd/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
+golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/app_go/main.go b/app_go/main.go
index 14aa5441d8..898f940d7b 100644
--- a/app_go/main.go
+++ b/app_go/main.go
@@ -9,14 +9,18 @@ import (
"net/http"
"os"
"runtime"
+ "strconv"
"strings"
"sync"
"time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
)
const (
serviceName = "devops-info-service"
- serviceVersion = "1.7.0"
+ serviceVersion = "1.8.0"
serviceDescription = "DevOps course info service"
serviceFramework = "Go net/http"
serviceLoggerName = "devops_info_service"
@@ -65,7 +69,7 @@ type RootResponse struct {
Endpoints []EndpointInfo `json:"endpoints"`
}
-type HealthResponse struct {
+type StatusResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
UptimeSeconds int64 `json:"uptime_seconds"`
@@ -76,13 +80,65 @@ var (
startTime = time.Now().UTC()
logMu sync.Mutex
logOutput io.Writer = os.Stdout
+ // metricsRegistry only exposes service metrics, matching the Python app.
+ metricsRegistry = prometheus.NewRegistry()
+ httpRequestsTotal = prometheus.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "http_requests_total",
+ Help: "Total HTTP requests handled by the service.",
+ },
+ []string{"method", "endpoint", "status_code"},
+ )
+ httpRequestDurationSeconds = prometheus.NewHistogramVec(
+ prometheus.HistogramOpts{
+ Name: "http_request_duration_seconds",
+ Help: "HTTP request duration in seconds.",
+ },
+ []string{"method", "endpoint", "status_code"},
+ )
+ httpRequestsInProgress = prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Name: "http_requests_in_progress",
+ Help: "HTTP requests currently being processed.",
+ },
+ []string{"method", "endpoint"},
+ )
+ endpointCallsTotal = prometheus.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "devops_info_endpoint_calls_total",
+ Help: "Total calls to application endpoints.",
+ },
+ []string{"endpoint"},
+ )
+ systemInfoDurationSeconds = prometheus.NewHistogram(
+ prometheus.HistogramOpts{
+ Name: "devops_info_system_info_duration_seconds",
+ Help: "Time spent collecting system information.",
+ },
+ )
+ metricsHTTPHandler = promhttp.HandlerFor(
+ metricsRegistry,
+ promhttp.HandlerOpts{},
+ )
// endpoints is a static list used to mirror the Python app output.
endpoints = []EndpointInfo{
{Path: "/", Method: http.MethodGet, Description: "Service information."},
- {Path: "/health", Method: http.MethodGet, Description: "Health check endpoint."},
+ {Path: "/health", Method: http.MethodGet, Description: "Health check."},
+ {Path: "/ready", Method: http.MethodGet, Description: "Readiness check."},
+ {Path: "/metrics", Method: http.MethodGet, Description: "Prometheus metrics."},
}
)
+func init() {
+ metricsRegistry.MustRegister(
+ httpRequestsTotal,
+ httpRequestDurationSeconds,
+ httpRequestsInProgress,
+ endpointCallsTotal,
+ systemInfoDurationSeconds,
+ )
+}
+
type responseRecorder struct {
http.ResponseWriter
statusCode int
@@ -101,6 +157,9 @@ func getServiceInfo() ServiceInfo {
// getSystemInfo returns host and runtime information.
func getSystemInfo() SystemInfo {
+ startedAt := time.Now()
+ defer systemInfoDurationSeconds.Observe(time.Since(startedAt).Seconds())
+
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
@@ -206,6 +265,19 @@ func listEndpoints() []EndpointInfo {
return endpoints
}
+func normalizeEndpointLabel(path string) string {
+ switch path {
+ case "/", "/health", "/metrics", "/ready":
+ return path
+ default:
+ return "unmatched"
+ }
+}
+
+func recordEndpointCall(endpoint string) {
+ endpointCallsTotal.WithLabelValues(endpoint).Inc()
+}
+
func newResponseRecorder(w http.ResponseWriter) *responseRecorder {
return &responseRecorder{
ResponseWriter: w,
@@ -263,6 +335,7 @@ func queryString(r *http.Request) string {
// mainHandler serves GET /.
func mainHandler(w http.ResponseWriter, r *http.Request) {
+ recordEndpointCall("/")
payload := RootResponse{
Service: getServiceInfo(),
System: getSystemInfo(),
@@ -276,8 +349,19 @@ func mainHandler(w http.ResponseWriter, r *http.Request) {
// healthHandler serves GET /health.
func healthHandler(w http.ResponseWriter, r *http.Request) {
- payload := HealthResponse{
- Status: "healthy",
+ recordEndpointCall("/health")
+ writeStatusResponse(w, "healthy")
+}
+
+// readinessHandler serves GET /ready.
+func readinessHandler(w http.ResponseWriter, r *http.Request) {
+ recordEndpointCall("/ready")
+ writeStatusResponse(w, "ready")
+}
+
+func writeStatusResponse(w http.ResponseWriter, status string) {
+ payload := StatusResponse{
+ Status: status,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05.000000-07:00"),
UptimeSeconds: getUptime().Seconds,
}
@@ -285,8 +369,21 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, payload)
}
+// metricsHandler serves GET /metrics.
+func metricsHandler(w http.ResponseWriter, r *http.Request) {
+ recordEndpointCall("/metrics")
+ metricsHTTPHandler.ServeHTTP(w, r)
+}
+
// notFound returns a JSON 404.
func notFound(w http.ResponseWriter, r *http.Request) {
+ emitLog("WARNING", serviceLoggerName, "request returned not found", map[string]any{
+ "client_ip": clientIP(r),
+ "method": r.Method,
+ "path": r.URL.Path,
+ "status_code": http.StatusNotFound,
+ "user_agent": r.Header.Get("User-Agent"),
+ })
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "Not Found",
"message": "Endpoint does not exist",
@@ -300,6 +397,10 @@ func router(w http.ResponseWriter, r *http.Request) {
mainHandler(w, r)
case r.URL.Path == "/health" && r.Method == http.MethodGet:
healthHandler(w, r)
+ case r.URL.Path == "/metrics" && r.Method == http.MethodGet:
+ metricsHandler(w, r)
+ case r.URL.Path == "/ready" && r.Method == http.MethodGet:
+ readinessHandler(w, r)
default:
notFound(w, r)
}
@@ -348,6 +449,27 @@ func requestLoggingMiddleware(next http.Handler) http.Handler {
})
}
+func metricsMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ endpoint := normalizeEndpointLabel(r.URL.Path)
+ httpRequestsInProgress.WithLabelValues(r.Method, endpoint).Inc()
+ defer httpRequestsInProgress.WithLabelValues(r.Method, endpoint).Dec()
+
+ startedAt := time.Now()
+ recorder := newResponseRecorder(w)
+
+ next.ServeHTTP(recorder, r)
+
+ statusCode := strconv.Itoa(recorder.statusCode)
+ httpRequestsTotal.WithLabelValues(r.Method, endpoint, statusCode).Inc()
+ httpRequestDurationSeconds.WithLabelValues(
+ r.Method,
+ endpoint,
+ statusCode,
+ ).Observe(time.Since(startedAt).Seconds())
+ })
+}
+
// writeJSON serializes a payload with the given status code.
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
@@ -378,7 +500,9 @@ func main() {
"version": serviceVersion,
})
- handler := requestLoggingMiddleware(recoverMiddleware(http.HandlerFunc(router)))
+ handler := requestLoggingMiddleware(
+ metricsMiddleware(recoverMiddleware(http.HandlerFunc(router))),
+ )
if err := http.ListenAndServe(addr, handler); err != nil {
emitLog("ERROR", serviceLoggerName, "server error", map[string]any{
"error": err.Error(),
diff --git a/app_go/main_test.go b/app_go/main_test.go
index 2622645ec9..32d1784ed3 100644
--- a/app_go/main_test.go
+++ b/app_go/main_test.go
@@ -6,6 +6,8 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "strconv"
+ "strings"
"testing"
)
@@ -22,20 +24,270 @@ func captureLogOutput(w io.Writer) func() {
}
}
-func decodeLogEntry(t *testing.T, buffer *bytes.Buffer) map[string]any {
+func decodeLogEntries(t *testing.T, buffer *bytes.Buffer) []map[string]any {
t.Helper()
lines := bytes.Split(bytes.TrimSpace(buffer.Bytes()), []byte("\n"))
- if len(lines) != 1 {
- t.Fatalf("expected exactly one log line, got %d", len(lines))
+ entries := make([]map[string]any, 0, len(lines))
+
+ for _, line := range lines {
+ if len(line) == 0 {
+ continue
+ }
+
+ var entry map[string]any
+ if err := json.Unmarshal(line, &entry); err != nil {
+ t.Fatalf("failed to decode log entry: %v", err)
+ }
+ entries = append(entries, entry)
+ }
+
+ if len(entries) == 0 {
+ t.Fatal("expected at least one log entry")
+ }
+
+ return entries
+}
+
+func decodeLogEntry(t *testing.T, buffer *bytes.Buffer) map[string]any {
+ t.Helper()
+
+ entries := decodeLogEntries(t, buffer)
+ if len(entries) != 1 {
+ t.Fatalf("expected exactly one log line, got %d", len(entries))
+ }
+
+ return entries[0]
+}
+
+func decodeJSONResponse[T any](t *testing.T, recorder *httptest.ResponseRecorder) T {
+ t.Helper()
+
+ var payload T
+ if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
+ t.Fatalf("failed to decode JSON response: %v", err)
+ }
+
+ return payload
+}
+
+func performRequest(handler http.Handler, method, path string) *httptest.ResponseRecorder {
+ request := httptest.NewRequest(method, path, nil)
+ request.RemoteAddr = "203.0.113.7:4321"
+ request.Header.Set("User-Agent", "go-test")
+
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+ return recorder
+}
+
+func metricValue(metricsText, sampleName string, labels map[string]string) (float64, bool) {
+ for _, line := range strings.Split(metricsText, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ fields := strings.Fields(line)
+ if len(fields) != 2 {
+ continue
+ }
+
+ metricName, metricLabels := parseMetricSample(fields[0])
+ if metricName != sampleName {
+ continue
+ }
+ if !labelsMatch(metricLabels, labels) {
+ continue
+ }
+
+ value, err := strconv.ParseFloat(fields[1], 64)
+ if err != nil {
+ return 0, false
+ }
+ return value, true
+ }
+
+ return 0, false
+}
+
+func parseMetricSample(sample string) (string, map[string]string) {
+ openBrace := strings.Index(sample, "{")
+ if openBrace == -1 {
+ return sample, map[string]string{}
+ }
+
+ name := sample[:openBrace]
+ labelText := strings.TrimSuffix(sample[openBrace+1:], "}")
+ labels := map[string]string{}
+ if labelText == "" {
+ return name, labels
+ }
+
+ for _, part := range strings.Split(labelText, ",") {
+ key, value, found := strings.Cut(part, "=")
+ if !found {
+ continue
+ }
+ labels[key] = strings.Trim(value, "\"")
+ }
+
+ return name, labels
+}
+
+func labelsMatch(actual map[string]string, expected map[string]string) bool {
+ for key, value := range expected {
+ if actual[key] != value {
+ return false
+ }
+ }
+ return true
+}
+
+func scrapeMetrics(t *testing.T) string {
+ t.Helper()
+
+ recorder := performRequest(http.HandlerFunc(metricsHandler), http.MethodGet, "/metrics")
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("expected metrics status %d, got %d", http.StatusOK, recorder.Code)
+ }
+
+ return recorder.Body.String()
+}
+
+func TestIndexReturnsExpectedJSONStructureAndTypes(t *testing.T) {
+ restore := captureLogOutput(io.Discard)
+ defer restore()
+
+ recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/")
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
+ }
+
+ payload := decodeJSONResponse[RootResponse](t, recorder)
+ if payload.Service.Name != serviceName {
+ t.Fatalf("expected service name %q, got %q", serviceName, payload.Service.Name)
+ }
+ if payload.Service.Framework != serviceFramework {
+ t.Fatalf("expected framework %q, got %q", serviceFramework, payload.Service.Framework)
+ }
+ if payload.Service.Version == "" {
+ t.Fatal("expected non-empty version")
+ }
+ if payload.System.Hostname == "" {
+ t.Fatal("expected hostname to be populated")
+ }
+ if payload.System.CPUCount < 1 {
+ t.Fatalf("expected cpu_count >= 1, got %d", payload.System.CPUCount)
+ }
+ if payload.Runtime.Seconds < 0 {
+ t.Fatalf("expected non-negative uptime, got %d", payload.Runtime.Seconds)
+ }
+ if payload.Request.ClientIP != "203.0.113.7" {
+ t.Fatalf("expected client_ip %q, got %q", "203.0.113.7", payload.Request.ClientIP)
+ }
+
+ routeIndex := map[string]bool{}
+ for _, endpoint := range payload.Endpoints {
+ routeIndex[endpoint.Method+" "+endpoint.Path] = true
}
- var entry map[string]any
- if err := json.Unmarshal(lines[0], &entry); err != nil {
- t.Fatalf("failed to decode log entry: %v", err)
+ for _, route := range []string{
+ http.MethodGet + " /",
+ http.MethodGet + " /health",
+ http.MethodGet + " /ready",
+ http.MethodGet + " /metrics",
+ } {
+ if !routeIndex[route] {
+ t.Fatalf("expected endpoint %q to be listed", route)
+ }
+ }
+}
+
+func TestHealthReturnsExpectedJSONStructureAndTypes(t *testing.T) {
+ restore := captureLogOutput(io.Discard)
+ defer restore()
+
+ recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/health")
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
+ }
+
+ payload := decodeJSONResponse[StatusResponse](t, recorder)
+ if payload.Status != "healthy" {
+ t.Fatalf("expected status %q, got %q", "healthy", payload.Status)
+ }
+ if payload.UptimeSeconds < 0 {
+ t.Fatalf("expected non-negative uptime, got %d", payload.UptimeSeconds)
+ }
+ if payload.Timestamp == "" {
+ t.Fatal("expected non-empty timestamp")
+ }
+}
+
+func TestReadyReturnsExpectedJSONStructureAndTypes(t *testing.T) {
+ restore := captureLogOutput(io.Discard)
+ defer restore()
+
+ recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/ready")
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
+ }
+
+ payload := decodeJSONResponse[StatusResponse](t, recorder)
+ if payload.Status != "ready" {
+ t.Fatalf("expected status %q, got %q", "ready", payload.Status)
+ }
+ if payload.UptimeSeconds < 0 {
+ t.Fatalf("expected non-negative uptime, got %d", payload.UptimeSeconds)
+ }
+ if payload.Timestamp == "" {
+ t.Fatal("expected non-empty timestamp")
+ }
+}
+
+func TestUnknownEndpointReturnsJSON404(t *testing.T) {
+ restore := captureLogOutput(io.Discard)
+ defer restore()
+
+ recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/missing")
+ if recorder.Code != http.StatusNotFound {
+ t.Fatalf("expected status %d, got %d", http.StatusNotFound, recorder.Code)
+ }
+
+ payload := decodeJSONResponse[map[string]string](t, recorder)
+ expected := map[string]string{
+ "error": "Not Found",
+ "message": "Endpoint does not exist",
+ }
+ if payload["error"] != expected["error"] || payload["message"] != expected["message"] {
+ t.Fatalf("expected %#v, got %#v", expected, payload)
+ }
+}
+
+func TestNotFoundEmitsJSONWarningLog(t *testing.T) {
+ var buffer bytes.Buffer
+ restore := captureLogOutput(&buffer)
+ defer restore()
+
+ recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/missing")
+ if recorder.Code != http.StatusNotFound {
+ t.Fatalf("expected status %d, got %d", http.StatusNotFound, recorder.Code)
}
- return entry
+ entry := decodeLogEntry(t, &buffer)
+ if entry["level"] != "WARNING" {
+ t.Fatalf("expected WARNING level, got %#v", entry["level"])
+ }
+ if entry["logger"] != serviceLoggerName {
+ t.Fatalf("expected logger %q, got %#v", serviceLoggerName, entry["logger"])
+ }
+ if entry["message"] != "request returned not found" {
+ t.Fatalf("expected message to be logged, got %#v", entry["message"])
+ }
+ if entry["status_code"] != float64(http.StatusNotFound) {
+ t.Fatalf("expected status_code %d, got %#v", http.StatusNotFound, entry["status_code"])
+ }
}
func TestRequestLoggingMiddlewareEmitsJSONAccessLog(t *testing.T) {
@@ -138,3 +390,76 @@ func TestRecoverMiddlewareEmitsJSONPanicLog(t *testing.T) {
t.Fatalf("expected client_ip to be logged, got %#v", entry["client_ip"])
}
}
+
+func TestMetricsEndpointExposesHTTPAndApplicationMetrics(t *testing.T) {
+ restore := captureLogOutput(io.Discard)
+ defer restore()
+
+ handler := metricsMiddleware(http.HandlerFunc(router))
+
+ performRequest(handler, http.MethodGet, "/")
+ performRequest(handler, http.MethodGet, "/health")
+ performRequest(handler, http.MethodGet, "/ready")
+ performRequest(handler, http.MethodGet, "/does-not-exist")
+
+ recorder := performRequest(handler, http.MethodGet, "/metrics")
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
+ }
+ if !strings.HasPrefix(recorder.Header().Get("Content-Type"), "text/plain") {
+ t.Fatalf("expected text/plain content type, got %q", recorder.Header().Get("Content-Type"))
+ }
+
+ metricsText := recorder.Body.String()
+ for _, tc := range []struct {
+ name string
+ labels map[string]string
+ }{
+ {name: "http_requests_total", labels: map[string]string{"method": "GET", "endpoint": "/", "status_code": "200"}},
+ {name: "http_requests_total", labels: map[string]string{"method": "GET", "endpoint": "/health", "status_code": "200"}},
+ {name: "http_requests_total", labels: map[string]string{"method": "GET", "endpoint": "/ready", "status_code": "200"}},
+ {name: "http_requests_total", labels: map[string]string{"method": "GET", "endpoint": "unmatched", "status_code": "404"}},
+ {name: "http_request_duration_seconds_count", labels: map[string]string{"method": "GET", "endpoint": "/", "status_code": "200"}},
+ {name: "devops_info_endpoint_calls_total", labels: map[string]string{"endpoint": "/"}},
+ {name: "devops_info_endpoint_calls_total", labels: map[string]string{"endpoint": "/ready"}},
+ {name: "devops_info_system_info_duration_seconds_count", labels: map[string]string{}},
+ } {
+ value, ok := metricValue(metricsText, tc.name, tc.labels)
+ if !ok || value < 1.0 {
+ t.Fatalf("expected %s with labels %#v to be >= 1, got ok=%v value=%v", tc.name, tc.labels, ok, value)
+ }
+ }
+
+ value, ok := metricValue(
+ metricsText,
+ "http_requests_in_progress",
+ map[string]string{"method": "GET", "endpoint": "/"},
+ )
+ if !ok || value != 0.0 {
+ t.Fatalf("expected in-progress gauge to be 0, got ok=%v value=%v", ok, value)
+ }
+}
+
+func TestMetricsCountInternalServerErrorsWithStatusLabels(t *testing.T) {
+ restore := captureLogOutput(io.Discard)
+ defer restore()
+
+ labels := map[string]string{"method": "GET", "endpoint": "/", "status_code": "500"}
+ before, _ := metricValue(scrapeMetrics(t), "http_requests_total", labels)
+
+ handler := metricsMiddleware(recoverMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ panic("boom")
+ })))
+ recorder := performRequest(handler, http.MethodGet, "/")
+ if recorder.Code != http.StatusInternalServerError {
+ t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, recorder.Code)
+ }
+
+ after, ok := metricValue(scrapeMetrics(t), "http_requests_total", labels)
+ if !ok {
+ t.Fatalf("expected %s with labels %#v to exist after panic request", "http_requests_total", labels)
+ }
+ if after != before+1.0 {
+ t.Fatalf("expected counter to increase by 1, got before=%v after=%v", before, after)
+ }
+}
From 6a349d99cb1a7ce7e9b5989277b5877c5e8498ab Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Fri, 27 Mar 2026 05:18:26 +0300
Subject: [PATCH 05/11] task 2
---
k8s/README.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++
k8s/deployment.yml | 4 +--
2 files changed, 82 insertions(+), 2 deletions(-)
diff --git a/k8s/README.md b/k8s/README.md
index c016b05e06..b61f7edfa6 100644
--- a/k8s/README.md
+++ b/k8s/README.md
@@ -36,3 +36,83 @@ kube-system Active 3m9s
```
+
+## Task 2 - Application Deployment
+
+The deployment uses `localt0aster/devops-app-py:1.9-dev` with 3 replicas, rolling updates, and resource requests and limits. The current manifest uses `GET /health` for liveness and `GET /ready` for readiness.
+
+
+Deployment rollout verification output
+
+```text
+$ kubectl delete deployment devops-app-py --cascade=foreground --wait=true
+deployment.apps "devops-app-py" deleted from default namespace
+
+$ kubectl apply -f k8s/deployment.yml
+deployment.apps/devops-app-py created
+
+$ kubectl rollout status deployment/devops-app-py --timeout=180s
+Waiting for deployment "devops-app-py" rollout to finish: 0 of 3 updated replicas are available...
+Waiting for deployment "devops-app-py" rollout to finish: 1 of 3 updated replicas are available...
+Waiting for deployment "devops-app-py" rollout to finish: 2 of 3 updated replicas are available...
+deployment "devops-app-py" successfully rolled out
+
+$ kubectl get deployment devops-app-py
+NAME READY UP-TO-DATE AVAILABLE AGE
+devops-app-py 3/3 3 3 8s
+
+$ kubectl get pods -l app.kubernetes.io/name=devops-app-py -o wide
+NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
+devops-app-py-76fc7985df-jq2tr 1/1 Running 0 8s 10.244.0.14 minikube
+devops-app-py-76fc7985df-jwpsf 1/1 Running 0 8s 10.244.0.13 minikube
+devops-app-py-76fc7985df-nwr58 1/1 Running 0 8s 10.244.0.12 minikube
+
+$ kubectl describe deployment devops-app-py
+Name: devops-app-py
+Namespace: default
+CreationTimestamp: Fri, 27 Mar 2026 05:16:21 +0300
+Labels: app.kubernetes.io/name=devops-app-py
+ app.kubernetes.io/part-of=devops-core-s26
+Annotations: deployment.kubernetes.io/revision: 1
+Selector: app.kubernetes.io/name=devops-app-py
+Replicas: 3 desired | 3 updated | 3 total | 3 available | 0 unavailable
+StrategyType: RollingUpdate
+MinReadySeconds: 0
+RollingUpdateStrategy: 1 max unavailable, 1 max surge
+Pod Template:
+ Labels: app.kubernetes.io/name=devops-app-py
+ app.kubernetes.io/part-of=devops-core-s26
+ Containers:
+ devops-app-py:
+ Image: localt0aster/devops-app-py:1.9-dev
+ Port: 5000/TCP (http)
+ Host Port: 0/TCP (http)
+ Limits:
+ cpu: 250m
+ memory: 256Mi
+ Requests:
+ cpu: 100m
+ memory: 128Mi
+ Liveness: http-get http://:http/health delay=10s timeout=2s period=10s #success=1 #failure=3
+ Readiness: http-get http://:http/ready delay=5s timeout=2s period=5s #success=1 #failure=3
+ Environment:
+ HOST: 0.0.0.0
+ PORT: 5000
+ Mounts:
+ Volumes:
+ Node-Selectors:
+ Tolerations:
+Conditions:
+ Type Status Reason
+ ---- ------ ------
+ Available True MinimumReplicasAvailable
+ Progressing True NewReplicaSetAvailable
+OldReplicaSets:
+NewReplicaSet: devops-app-py-76fc7985df (3/3 replicas created)
+Events:
+ Type Reason Age From Message
+ ---- ------ ---- ---- -------
+ Normal ScalingReplicaSet 9s deployment-controller Scaled up replica set devops-app-py-76fc7985df from 0 to 3
+```
+
+
diff --git a/k8s/deployment.yml b/k8s/deployment.yml
index a65e8712d1..636f7cca7a 100644
--- a/k8s/deployment.yml
+++ b/k8s/deployment.yml
@@ -24,7 +24,7 @@ spec:
spec:
containers:
- name: devops-app-py
- image: localt0aster/devops-app-py:1.8
+ image: localt0aster/devops-app-py:1.9-dev
imagePullPolicy: IfNotPresent
ports:
- name: http
@@ -44,7 +44,7 @@ spec:
failureThreshold: 3
readinessProbe:
httpGet:
- path: /health
+ path: /ready
port: http
initialDelaySeconds: 5
periodSeconds: 5
From eeb6bbc77f02fdaf419f66fafa557af0aa64c1e0 Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Fri, 27 Mar 2026 05:35:48 +0300
Subject: [PATCH 06/11] task 3
---
k8s/README.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++
k8s/service.yml | 16 ++++++----
2 files changed, 88 insertions(+), 6 deletions(-)
diff --git a/k8s/README.md b/k8s/README.md
index b61f7edfa6..325a8fb4cd 100644
--- a/k8s/README.md
+++ b/k8s/README.md
@@ -116,3 +116,81 @@ Events:
```
+
+## Task 3 - Service Configuration
+
+The Service uses type `NodePort` and targets the Deployment Pods with the `app.kubernetes.io/name=devops-app-py` label. It exposes service port `80` and forwards traffic to container port `5000` on a fixed NodePort, `30080`.
+
+For connectivity verification, I used `kubectl port-forward service/devops-app-py-service 8080:80`. I tested `minikube service ... --url` first, but in this Docker-driver setup the returned node IP was not directly reachable from the host, so port-forward was the practical local-access path.
+
+
+Service verification output
+
+```text
+$ kubectl apply -f k8s/service.yml
+service/devops-app-py-service unchanged
+
+$ kubectl get services
+NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
+devops-app-py-service NodePort 10.110.168.128 80:30080/TCP 32s
+kubernetes ClusterIP 10.96.0.1 443/TCP 80m
+
+$ kubectl describe service devops-app-py-service
+Name: devops-app-py-service
+Namespace: default
+Labels: app.kubernetes.io/name=devops-app-py
+ app.kubernetes.io/part-of=devops-core-s26
+Annotations:
+Selector: app.kubernetes.io/name=devops-app-py
+Type: NodePort
+IP Family Policy: SingleStack
+IP Families: IPv4
+IP: 10.110.168.128
+IPs: 10.110.168.128
+Port: http 80/TCP
+TargetPort: 5000/TCP
+NodePort: http 30080/TCP
+Endpoints: 10.244.0.12:5000,10.244.0.13:5000,10.244.0.14:5000
+Session Affinity: None
+External Traffic Policy: Cluster
+Internal Traffic Policy: Cluster
+Events:
+
+$ kubectl get endpoints devops-app-py-service
+Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
+NAME ENDPOINTS AGE
+devops-app-py-service 10.244.0.12:5000,10.244.0.13:5000,10.244.0.14:5000 32s
+
+$ kubectl port-forward service/devops-app-py-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
+Handling connection for 8080
+
+$ curl -fsSL 127.0.0.1:8080 | jq .service.name
+"devops-info-service"
+
+$ curl -fsSL 127.0.0.1:8080/health | jq .status
+"healthy"
+
+$ curl -fsSL 127.0.0.1:8080/ready | jq .status
+"ready"
+
+$ curl -fsSL 127.0.0.1:8080/metrics | head -n 12
+# HELP http_requests_total Total HTTP requests handled by the service.
+# TYPE http_requests_total counter
+http_requests_total{endpoint="/ready",method="GET",status_code="200"} 180.0
+http_requests_total{endpoint="/health",method="GET",status_code="200"} 90.0
+http_requests_total{endpoint="/",method="GET",status_code="200"} 2.0
+http_requests_total{endpoint="/metrics",method="GET",status_code="200"} 1.0
+# HELP http_requests_created Total HTTP requests handled by the service.
+# TYPE http_requests_created gauge
+http_requests_created{endpoint="/ready",method="GET",status_code="200"} 1.7745777896655755e+09
+http_requests_created{endpoint="/health",method="GET",status_code="200"} 1.7745778018120363e+09
+http_requests_created{endpoint="/",method="GET",status_code="200"} 1.7745779956714542e+09
+http_requests_created{endpoint="/metrics",method="GET",status_code="200"} 1.7745779957933705e+09
+```
+
+
diff --git a/k8s/service.yml b/k8s/service.yml
index 4b8e7275f6..a6ebff9108 100644
--- a/k8s/service.yml
+++ b/k8s/service.yml
@@ -1,13 +1,17 @@
apiVersion: v1
kind: Service
metadata:
- name: your-app-service
+ name: devops-app-py-service
+ labels:
+ app.kubernetes.io/name: devops-app-py
+ app.kubernetes.io/part-of: devops-core-s26
spec:
type: NodePort
selector:
- app: your-app # Must match Deployment labels
+ app.kubernetes.io/name: devops-app-py
ports:
- - protocol: TCP
- port: 80 # Service port
- targetPort: 8000 # Container port
- nodePort: 30080 # Optional: specific node port (30000-32767)
+ - name: http
+ protocol: TCP
+ port: 80
+ targetPort: 5000
+ nodePort: 30080
From 1578f10cdcb0d526541089e84a4d8b365e29ee53 Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Fri, 27 Mar 2026 06:20:14 +0300
Subject: [PATCH 07/11] task 45
---
k8s/README.md | 456 ++++++++++++++++++++++++++++++++++++++++++++-
k8s/deployment.yml | 4 +-
2 files changed, 457 insertions(+), 3 deletions(-)
diff --git a/k8s/README.md b/k8s/README.md
index 325a8fb4cd..01f5ed71a6 100644
--- a/k8s/README.md
+++ b/k8s/README.md
@@ -39,7 +39,7 @@ kube-system Active 3m9s
## Task 2 - Application Deployment
-The deployment uses `localt0aster/devops-app-py:1.9-dev` with 3 replicas, rolling updates, and resource requests and limits. The current manifest uses `GET /health` for liveness and `GET /ready` for readiness.
+The initial Task 2 deployment used `localt0aster/devops-app-py:1.9-dev` with 3 replicas, rolling updates, and resource requests and limits. At that stage, the probes were `GET /health` for liveness and `GET /ready` for readiness. Task 4 later scaled the manifest to 5 replicas and tightened the rollout strategy.
Deployment rollout verification output
@@ -194,3 +194,457 @@ http_requests_created{endpoint="/metrics",method="GET",status_code="200"} 1.7745
```
+
+## Task 4 - Scaling and Updates
+
+I scaled the Deployment declaratively to 5 replicas and verified that all 5 Pods were running. For the rolling-update portion, I changed the pod template with a temporary `LOG_LEVEL=DEBUG` environment variable. An in-cluster probe exposed a brief failed request with `maxUnavailable: 1`, so I changed the strategy to `maxUnavailable: 0` and reran the rollout. With that stricter strategy, the Service returned `200` for 35 consecutive `/ready` checks during the rollout. I then used `kubectl rollout undo` and returned the live Deployment to the baseline `1.9-dev` pod template while keeping the safer rollout strategy in the manifest.
+
+
+Scaling to 5 replicas
+
+```text
+$ kubectl apply -f k8s/deployment.yml
+deployment.apps/devops-app-py configured
+
+$ kubectl rollout status deployment/devops-app-py --timeout=180s
+Waiting for deployment "devops-app-py" rollout to finish: 3 of 5 updated replicas are available...
+Waiting for deployment "devops-app-py" rollout to finish: 4 of 5 updated replicas are available...
+deployment "devops-app-py" successfully rolled out
+
+$ kubectl get deployment devops-app-py -o wide
+NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR
+devops-app-py 5/5 5 5 21m devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/name=devops-app-py
+
+$ kubectl get pods -l app.kubernetes.io/name=devops-app-py -o wide
+NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
+devops-app-py-76fc7985df-jmnrd 1/1 Running 0 13s 10.244.0.16 minikube
+devops-app-py-76fc7985df-jq2tr 1/1 Running 0 21m 10.244.0.14 minikube
+devops-app-py-76fc7985df-jrgms 1/1 Running 0 13s 10.244.0.15 minikube
+devops-app-py-76fc7985df-jwpsf 1/1 Running 0 21m 10.244.0.13 minikube
+devops-app-py-76fc7985df-nwr58 1/1 Running 0 21m 10.244.0.12 minikube
+```
+
+
+
+
+Rolling update with corrected zero-downtime strategy
+
+```text
+$ kubectl apply -f k8s/deployment.yml
+deployment.apps/devops-app-py configured
+
+$ kubectl rollout status deployment/devops-app-py --timeout=240s
+Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination...
+Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination...
+Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination...
+deployment "devops-app-py" successfully rolled out
+
+$ kubectl get rs -l app.kubernetes.io/name=devops-app-py -o wide
+NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR
+devops-app-py-65fc658668 5 5 5 6m45s devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/name=devops-app-py,pod-template-hash=65fc658668
+devops-app-py-76fc7985df 0 0 0 29m devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/name=devops-app-py,pod-template-hash=76fc7985df
+
+$ kubectl get deployment devops-app-py -o wide
+NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR
+devops-app-py 5/5 5 5 29m devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/name=devops-app-py
+
+$ kubectl rollout history deployment/devops-app-py
+deployment.apps/devops-app-py
+REVISION CHANGE-CAUSE
+5
+6
+```
+
+
+
+
+In-cluster readiness probe during rollout
+
+```text
+$ kubectl run task4-probe-zdt \
+ --image=curlimages/curl --rm -i --command -- \
+ sh -c '
+ for i in $(seq 1 35); do
+ code=$(curl -sS -o /dev/null -w "%{http_code}" http://devops-app-py-service/ready)
+ printf "%s %s\n" "$(date +%H:%M:%S)" "$code"
+ sleep 1
+ done
+ '
+All commands and output from this session will be recorded in container logs, including credentials and sensitive information passed through the command prompt.
+If you don't see a command prompt, try pressing enter.
+02:44:47 200
+02:44:48 200
+02:44:49 200
+02:44:50 200
+02:44:51 200
+02:44:52 200
+02:44:53 200
+02:44:54 200
+02:44:55 200
+02:44:56 200
+02:44:57 200
+02:44:58 200
+02:44:59 200
+02:45:00 200
+02:45:01 200
+02:45:02 200
+02:45:03 200
+02:45:04 200
+02:45:05 200
+02:45:06 200
+02:45:07 200
+02:45:08 200
+02:45:09 200
+02:45:10 200
+02:45:11 200
+02:45:12 200
+02:45:13 200
+02:45:14 200
+02:45:15 200
+02:45:16 200
+02:45:17 200
+02:45:18 200
+02:45:19 200
+02:45:20 200
+pod "task4-probe-zdt" deleted from default namespace
+```
+
+
+
+
+Rollback and rollout history
+
+```text
+$ kubectl rollout undo deployment/devops-app-py
+deployment.apps/devops-app-py rolled back
+
+$ kubectl rollout status deployment/devops-app-py --timeout=240s
+Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated...
+Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination...
+Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination...
+Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination...
+deployment "devops-app-py" successfully rolled out
+
+$ kubectl get rs -l app.kubernetes.io/name=devops-app-py -o wide
+NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR
+devops-app-py-65fc658668 0 0 0 7m45s devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/name=devops-app-py,pod-template-hash=65fc658668
+devops-app-py-76fc7985df 5 5 5 30m devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/name=devops-app-py,pod-template-hash=76fc7985df
+
+$ kubectl get deployment devops-app-py -o wide
+NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR
+devops-app-py 5/5 5 5 30m devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/name=devops-app-py
+
+$ kubectl rollout history deployment/devops-app-py
+deployment.apps/devops-app-py
+REVISION CHANGE-CAUSE
+6
+7
+```
+
+
+
+## Task 5 - Documentation
+
+### Architecture Overview
+
+The final Kubernetes layout is one `Deployment` and one `NodePort` `Service` in the default namespace. The Deployment runs 5 Flask Pods from `localt0aster/devops-app-py:1.9-dev`, and the Service load-balances traffic from port `80` to container port `5000`.
+
+```mermaid
+flowchart LR
+ Client[Client]
+ Service[NodePort Service
devops-app-py-service
80 -> 5000
30080/TCP]
+ Deployment[Deployment
devops-app-py
5 replicas]
+ Pod1[Pod
/]
+ Pod2[Pod
/health]
+ Pod3[Pod
/ready]
+ Pod4[Pod
/metrics]
+ Pod5[Pod
5000/TCP]
+
+ Client --> Service
+ Service --> Deployment
+ Deployment --> Pod1
+ Deployment --> Pod2
+ Deployment --> Pod3
+ Deployment --> Pod4
+ Deployment --> Pod5
+```
+
+The resource strategy is intentionally small and predictable for a local lab cluster: each Pod requests `100m` CPU and `128Mi` memory, with limits of `250m` CPU and `256Mi` memory. For rollouts, the Deployment now uses `maxSurge: 1` and `maxUnavailable: 0` to preserve availability during Pod replacement.
+
+### Manifest Files
+
+- `k8s/deployment.yml`: defines the `Deployment`, `5` replicas, the `1.9-dev` Python image, labels/selectors, container port, `HOST` and `PORT` environment variables, resource requests and limits, and liveness/readiness probes. `maxUnavailable: 0` was chosen after testing showed that `1` could still allow a transient failed request during rollout.
+- `k8s/service.yml`: defines the `NodePort` `Service`, maps service port `80` to target port `5000`, uses node port `30080`, and selects Pods by `app.kubernetes.io/name=devops-app-py`.
+
+### Deployment Evidence
+
+
+Final cluster evidence
+
+```text
+$ kubectl apply -f k8s/deployment.yml
+deployment.apps/devops-app-py configured
+
+$ kubectl get all
+NAME READY STATUS RESTARTS AGE
+pod/devops-app-py-76fc7985df-6hmn5 1/1 Running 0 52s
+pod/devops-app-py-76fc7985df-6rk64 1/1 Running 0 69s
+pod/devops-app-py-76fc7985df-hr29v 1/1 Running 0 61s
+pod/devops-app-py-76fc7985df-ptjkm 1/1 Running 0 78s
+pod/devops-app-py-76fc7985df-t6d7b 1/1 Running 0 44s
+
+NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
+service/devops-app-py-service NodePort 10.110.168.128 80:30080/TCP 27m
+service/kubernetes ClusterIP 10.96.0.1 443/TCP 107m
+
+NAME READY UP-TO-DATE AVAILABLE AGE
+deployment.apps/devops-app-py 5/5 5 5 30m
+
+NAME DESIRED CURRENT READY AGE
+replicaset.apps/devops-app-py-65fc658668 0 0 0 8m20s
+replicaset.apps/devops-app-py-76fc7985df 5 5 5 30m
+
+$ kubectl get pods,svc -o wide
+NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
+pod/devops-app-py-76fc7985df-6hmn5 1/1 Running 0 52s 10.244.0.47 minikube
+pod/devops-app-py-76fc7985df-6rk64 1/1 Running 0 69s 10.244.0.45 minikube
+pod/devops-app-py-76fc7985df-hr29v 1/1 Running 0 61s 10.244.0.46 minikube
+pod/devops-app-py-76fc7985df-ptjkm 1/1 Running 0 78s 10.244.0.44 minikube
+pod/devops-app-py-76fc7985df-t6d7b 1/1 Running 0 44s 10.244.0.48 minikube
+
+NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR
+service/devops-app-py-service NodePort 10.110.168.128 80:30080/TCP 27m app.kubernetes.io/name=devops-app-py
+service/kubernetes ClusterIP 10.96.0.1 443/TCP 107m
+
+$ kubectl describe deployment devops-app-py
+Name: devops-app-py
+Namespace: default
+CreationTimestamp: Fri, 27 Mar 2026 05:16:21 +0300
+Labels: app.kubernetes.io/name=devops-app-py
+ app.kubernetes.io/part-of=devops-core-s26
+Annotations: deployment.kubernetes.io/revision: 7
+Selector: app.kubernetes.io/name=devops-app-py
+Replicas: 5 desired | 5 updated | 5 total | 5 available | 0 unavailable
+StrategyType: RollingUpdate
+MinReadySeconds: 0
+RollingUpdateStrategy: 0 max unavailable, 1 max surge
+Pod Template:
+ Labels: app.kubernetes.io/name=devops-app-py
+ app.kubernetes.io/part-of=devops-core-s26
+ Containers:
+ devops-app-py:
+ Image: localt0aster/devops-app-py:1.9-dev
+ Port: 5000/TCP (http)
+ Host Port: 0/TCP (http)
+ Limits:
+ cpu: 250m
+ memory: 256Mi
+ Requests:
+ cpu: 100m
+ memory: 128Mi
+ Liveness: http-get http://:http/health delay=10s timeout=2s period=10s #success=1 #failure=3
+ Readiness: http-get http://:http/ready delay=5s timeout=2s period=5s #success=1 #failure=3
+ Environment:
+ HOST: 0.0.0.0
+ PORT: 5000
+ Mounts:
+ Volumes:
+ Node-Selectors:
+ Tolerations:
+Conditions:
+ Type Status Reason
+ ---- ------ ------
+ Available True MinimumReplicasAvailable
+ Progressing True NewReplicaSetAvailable
+OldReplicaSets: devops-app-py-65fc658668 (0/0 replicas created)
+NewReplicaSet: devops-app-py-76fc7985df (5/5 replicas created)
+Events:
+ Type Reason Age From Message
+ ---- ------ ---- ---- -------
+ Normal ScalingReplicaSet 30m deployment-controller Scaled up replica set devops-app-py-76fc7985df from 0 to 3
+ Normal ScalingReplicaSet 9m21s deployment-controller Scaled up replica set devops-app-py-76fc7985df from 3 to 5
+ Normal ScalingReplicaSet 8m20s deployment-controller Scaled up replica set devops-app-py-65fc658668 from 0 to 1
+ Normal ScalingReplicaSet 8m20s deployment-controller Scaled up replica set devops-app-py-65fc658668 from 1 to 2
+ Normal ScalingReplicaSet 8m12s deployment-controller Scaled down replica set devops-app-py-76fc7985df from 4 to 3
+ Normal ScalingReplicaSet 8m12s deployment-controller Scaled up replica set devops-app-py-65fc658668 from 2 to 3
+ Normal ScalingReplicaSet 8m12s deployment-controller Scaled down replica set devops-app-py-76fc7985df from 3 to 2
+ Normal ScalingReplicaSet 8m12s deployment-controller Scaled up replica set devops-app-py-65fc658668 from 3 to 4
+ Normal ScalingReplicaSet 8m3s deployment-controller Scaled down replica set devops-app-py-76fc7985df from 2 to 1
+ Normal ScalingReplicaSet 4m58s (x2 over 8m20s) deployment-controller Scaled down replica set devops-app-py-76fc7985df from 5 to 4
+ Normal ScalingReplicaSet 3m39s (x22 over 8m3s) deployment-controller (combined from similar events): Scaled up replica set devops-app-py-76fc7985df from 0 to 1
+
+$ kubectl run task5-curl \
+ --image=curlimages/curl \
+ \
+ --rm -i \
+ --command -- \
+ sh -c '
+ curl -fsS http://devops-app-py-service
+ printf "\n\n"
+ curl -fsS http://devops-app-py-service/health
+ printf "\n\n"
+ curl -fsS http://devops-app-py-service/ready
+ printf "\n\n"
+ curl -fsS http://devops-app-py-service/metrics | head -n 12
+ '
+{
+ "endpoints": [
+ {
+ "description": "Service information.",
+ "method": "GET",
+ "path": "/"
+ },
+ {
+ "description": "Health check.",
+ "method": "GET",
+ "path": "/health"
+ },
+ {
+ "description": "Prometheus metrics.",
+ "method": "GET",
+ "path": "/metrics"
+ },
+ {
+ "description": "Readiness check.",
+ "method": "GET",
+ "path": "/ready"
+ }
+ ],
+ "request": {
+ "client_ip": "10.244.0.49",
+ "method": "GET",
+ "path": "/",
+ "user_agent": "curl/8.12.1"
+ },
+ "runtime": {
+ "human": "0 hours, 1 minutes",
+ "seconds": 61
+ },
+ "service": {
+ "description": "DevOps course info service",
+ "framework": "Flask",
+ "name": "devops-info-service",
+ "version": "1.8.0"
+ },
+ "system": {
+ "architecture": "x86_64",
+ "cpu_count": 8,
+ "hostname": "devops-app-py-76fc7985df-6rk64",
+ "platform": "Linux",
+ "platform_version": "Alpine Linux v3.23",
+ "python_version": "3.14.3"
+ }
+}
+
+
+{
+ "status": "healthy",
+ "timestamp": "2026-03-27T02:47:15.357513+00:00",
+ "uptime_seconds": 70
+}
+
+
+{
+ "status": "ready",
+ "timestamp": "2026-03-27T02:47:15.363373+00:00",
+ "uptime_seconds": 53
+}
+
+
+# HELP http_requests_total Total HTTP requests handled by the service.
+# TYPE http_requests_total counter
+http_requests_total{endpoint="/ready",method="GET",status_code="200"} 13.0
+http_requests_total{endpoint="/health",method="GET",status_code="200"} 5.0
+http_requests_total{endpoint="/",method="GET",status_code="200"} 1.0
+# HELP http_requests_created Total HTTP requests handled by the service.
+# TYPE http_requests_created gauge
+http_requests_created{endpoint="/ready",method="GET",status_code="200"} 1.7745795737986815e+09
+http_requests_created{endpoint="/health",method="GET",status_code="200"} 1.7745795857890186e+09
+http_requests_created{endpoint="/",method="GET",status_code="200"} 1.774579635349531e+09
+# HELP http_request_duration_seconds HTTP request duration in seconds.
+# TYPE http_request_duration_seconds histogram
+pod "task5-curl" deleted from default namespace
+```
+
+
+
+### Operations Performed
+
+1. Deployment and verification:
+
+ ```bash
+ kubectl apply -f k8s/deployment.yml
+ kubectl rollout status deployment/devops-app-py
+ kubectl get deployment devops-app-py -o wide
+ kubectl get pods -l app.kubernetes.io/name=devops-app-py -o wide
+ kubectl describe deployment devops-app-py
+ ```
+
+2. Service setup and access checks:
+
+ ```bash
+ kubectl apply -f k8s/service.yml
+ kubectl describe service devops-app-py-service
+ kubectl port-forward service/devops-app-py-service 8080:80
+ kubectl run task5-curl --image=curlimages/curl --rm -i --command -- sh
+ ```
+
+3. Scaling to 5 replicas:
+
+ ```bash
+ kubectl apply -f k8s/deployment.yml
+ kubectl rollout status deployment/devops-app-py --timeout=180s
+ kubectl get pods -l app.kubernetes.io/name=devops-app-py -o wide
+ ```
+
+4. Rolling updates and rollback:
+
+ ```bash
+ kubectl apply -f k8s/deployment.yml
+ kubectl rollout status deployment/devops-app-py --timeout=240s
+ kubectl rollout history deployment/devops-app-py
+ kubectl rollout undo deployment/devops-app-py
+ ```
+
+### Production Considerations
+
+- Health checks: `/health` is used for liveness and `/ready` is used for readiness so Kubernetes only sends traffic to Pods that are actually prepared to serve requests.
+- Rollout safety: `maxUnavailable: 0` and `maxSurge: 1` were chosen to keep capacity available during updates. This was not just theoretical; the strategy was tightened after observing a transient failed request with `maxUnavailable: 1`.
+- Resource limits: `100m/128Mi` requests and `250m/256Mi` limits are reasonable for a lab environment and protect the single-node cluster from noisy-neighbor behavior.
+- Observability: the app exposes `/metrics`, which is ready for Prometheus scraping. `kubectl describe`, rollout history, and event inspection were enough for this lab, but production should add centralized logs and dashboards.
+- Further improvements: use image digests instead of mutable tags, add a `PodDisruptionBudget`, enable `HorizontalPodAutoscaler`, isolate workloads in a dedicated namespace, and place an Ingress with TLS in front of the NodePort service.
+
+### Challenges & Solutions
+
+- `minikube service devops-app-py-service --url` returned a valid NodePort URL, but with the Docker driver that node IP was not directly reachable from the host. I used `kubectl port-forward` for local testing and an in-cluster curl Pod for authoritative service verification.
+- The first zero-downtime test with `maxUnavailable: 1` produced a brief failed request during rollout. Instead of papering over it, I changed the Deployment strategy to `maxUnavailable: 0` and reran the test until the probe showed a clean `200` sequence.
+- Host-side port-forward tests can introduce their own connection artifacts during fast backend replacement. The more reliable method here was probing the Kubernetes Service from inside the cluster.
diff --git a/k8s/deployment.yml b/k8s/deployment.yml
index 636f7cca7a..d54bc202ff 100644
--- a/k8s/deployment.yml
+++ b/k8s/deployment.yml
@@ -6,13 +6,13 @@ metadata:
app.kubernetes.io/name: devops-app-py
app.kubernetes.io/part-of: devops-core-s26
spec:
- replicas: 3
+ replicas: 5
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
- maxUnavailable: 1
+ maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: devops-app-py
From 1831a0a1e0260df98908d737daf9488e6f721a67 Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Fri, 27 Mar 2026 06:50:31 +0300
Subject: [PATCH 08/11] fix: zlib cve in `python:3.14-alpine`
---
app_python/Dockerfile | 1 +
1 file changed, 1 insertion(+)
diff --git a/app_python/Dockerfile b/app_python/Dockerfile
index 2c59bb0ca7..a123b9e3be 100644
--- a/app_python/Dockerfile
+++ b/app_python/Dockerfile
@@ -1,4 +1,5 @@
FROM python:3.14-alpine
+RUN apk upgrade -U
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
From 5a4c8963865a48331bc397dc137d98e76df64da3 Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Fri, 27 Mar 2026 06:59:49 +0300
Subject: [PATCH 09/11] fix: go mod tidy
---
app_go/go.mod | 13 -------------
app_go/go.sum | 26 --------------------------
2 files changed, 39 deletions(-)
diff --git a/app_go/go.mod b/app_go/go.mod
index 557f282ff5..364c72ba68 100644
--- a/app_go/go.mod
+++ b/app_go/go.mod
@@ -5,27 +5,14 @@ go 1.25.0
require github.com/prometheus/client_golang v1.23.2
require (
- cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/creack/pty v1.1.24 // indirect
- github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
- github.com/golang/protobuf v1.5.4 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
- github.com/rogpeppe/go-internal v1.14.1 // indirect
- github.com/yuin/goldmark v1.8.2 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
- golang.org/x/mod v0.34.0 // indirect
- golang.org/x/net v0.52.0 // indirect
- golang.org/x/oauth2 v0.36.0 // indirect
- golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
- golang.org/x/telemetry v0.0.0-20260316223853-b6b0c46d1ccd // indirect
- golang.org/x/text v0.35.0 // indirect
- golang.org/x/tools v0.43.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
diff --git a/app_go/go.sum b/app_go/go.sum
index 3e3948f37d..2895e9cec2 100644
--- a/app_go/go.sum
+++ b/app_go/go.sum
@@ -1,17 +1,9 @@
-cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
-cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
-github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
-github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
-github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
-github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
@@ -30,32 +22,14 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
-github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
-github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
-github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
-golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
-golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
-golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
-golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
-golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
-golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/telemetry v0.0.0-20260316223853-b6b0c46d1ccd h1:QbR6Giw8AyR6v6Vff72jiZRUdZnetfgYRndQuKa806k=
-golang.org/x/telemetry v0.0.0-20260316223853-b6b0c46d1ccd/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
-golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
-golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
-golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
-golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
From a5f51ab7b719076c350c10cdd68ee4e8ebb9ea86 Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Fri, 27 Mar 2026 07:12:25 +0300
Subject: [PATCH 10/11] fix: snyk setup
---
.github/workflows/go-snyk.yml | 40 +++++++++++++++++++++++++++++++
.github/workflows/python-snyk.yml | 2 +-
2 files changed, 41 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/go-snyk.yml
diff --git a/.github/workflows/go-snyk.yml b/.github/workflows/go-snyk.yml
new file mode 100644
index 0000000000..d9d1819e0c
--- /dev/null
+++ b/.github/workflows/go-snyk.yml
@@ -0,0 +1,40 @@
+name: Go Snyk Scan
+
+on:
+ push:
+ paths:
+ - app_go/**
+ - .github/workflows/go-snyk.yml
+ pull_request:
+ branches:
+ - master
+ paths:
+ - app_go/**
+ - .github/workflows/go-snyk.yml
+
+jobs:
+ snyk:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: ./app_go
+ steps:
+ - uses: actions/checkout@v6
+ - name: Setup Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: ./app_go/go.mod
+ cache-dependency-path: ./app_go/go.sum
+ - name: Download Go modules
+ run: go mod download
+ - name: Setup Snyk CLI
+ uses: snyk/actions/setup@master
+ - name: Run Snyk dependency scan (or skip)
+ env:
+ SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
+ run: |
+ if [ -z "${SNYK_TOKEN:-}" ]; then
+ echo "SNYK_TOKEN secret not set; skipping Snyk dependency scan."
+ exit 0
+ fi
+ snyk test --severity-threshold=medium --fail-on=upgradable
diff --git a/.github/workflows/python-snyk.yml b/.github/workflows/python-snyk.yml
index a9967bc9b9..e389c1065c 100644
--- a/.github/workflows/python-snyk.yml
+++ b/.github/workflows/python-snyk.yml
@@ -34,4 +34,4 @@ jobs:
echo "SNYK_TOKEN secret not set; skipping Snyk dependency scan."
exit 0
fi
- snyk test --severity-threshold=high
+ snyk test --severity-threshold=medium --fail-on=upgradable
From 8cf1f307c605072c31cc1ec9edc3b00f6470dcc4 Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Fri, 27 Mar 2026 07:19:42 +0300
Subject: [PATCH 11/11] fix: requests cve
---
app_python/poetry.lock | 34 ++++++++++++++++++----------------
1 file changed, 18 insertions(+), 16 deletions(-)
diff --git a/app_python/poetry.lock b/app_python/poetry.lock
index 748bfe2685..5eb5476447 100644
--- a/app_python/poetry.lock
+++ b/app_python/poetry.lock
@@ -353,14 +353,14 @@ dotenv = ["python-dotenv"]
[[package]]
name = "gunicorn"
-version = "25.1.0"
+version = "25.3.0"
description = "WSGI HTTP Server for UNIX"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
- {file = "gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b"},
- {file = "gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616"},
+ {file = "gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660"},
+ {file = "gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889"},
]
[package.dependencies]
@@ -368,6 +368,7 @@ packaging = "*"
[package.extras]
eventlet = ["eventlet (>=0.40.3)"]
+fast = ["gunicorn_h1c (>=0.6.3)"]
gevent = ["gevent (>=24.10.1)"]
http2 = ["h2 (>=4.1.0)"]
setproctitle = ["setproctitle"]
@@ -663,14 +664,14 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests
[[package]]
name = "pytest-cov"
-version = "7.0.0"
+version = "7.1.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
- {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
- {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
+ {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"},
+ {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"},
]
[package.dependencies]
@@ -683,25 +684,26 @@ testing = ["process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "requests"
-version = "2.32.5"
+version = "2.33.0"
description = "Python HTTP for Humans."
optional = false
-python-versions = ">=3.9"
+python-versions = ">=3.10"
groups = ["main"]
files = [
- {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
- {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
+ {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"},
+ {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"},
]
[package.dependencies]
-certifi = ">=2017.4.17"
+certifi = ">=2023.5.7"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
-urllib3 = ">=1.21.1,<3"
+urllib3 = ">=1.26,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
-use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
[[package]]
name = "urllib3"
@@ -723,14 +725,14 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]]
name = "werkzeug"
-version = "3.1.6"
+version = "3.1.7"
description = "The comprehensive WSGI web application library."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131"},
- {file = "werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25"},
+ {file = "werkzeug-3.1.7-py3-none-any.whl", hash = "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f"},
+ {file = "werkzeug-3.1.7.tar.gz", hash = "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351"},
]
[package.dependencies]