From 058e429a0f19cc8b2d3dc4d0ecfa79f4ace3c397 Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Fri, 10 Apr 2026 02:52:52 +0300
Subject: [PATCH 1/4] feat(app_python): add persistent visits endpoint
---
app_python/README.md | 33 +++-
app_python/poetry.lock | 293 ++++++++++++++---------------
app_python/pyproject.toml | 2 +-
app_python/src/router.py | 73 ++++++-
app_python/tests/conftest.py | 6 +
app_python/tests/test_endpoints.py | 61 ++++++
6 files changed, 311 insertions(+), 157 deletions(-)
diff --git a/app_python/README.md b/app_python/README.md
index 78a0b12cc8..e9e3dd1bca 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, readiness, and Prometheus metrics endpoints for monitoring.
+Small Flask web service that reports service metadata, system information, runtime uptime, and basic request details. Includes a persistent visits counter stored at `/data/visits`, plus health, readiness, and Prometheus metrics endpoints for monitoring.
## Prerequisites
@@ -53,10 +53,26 @@ Gunicorn access logs are emitted as JSON so Loki can parse request fields cleanl
## API Endpoints
- `GET /` - Service and system information
+- `GET /visits` - Current persisted visit counter
- `GET /health` - Health check
- `GET /ready` - Readiness check
- `GET /metrics` - Prometheus metrics exposition
+## Visits Counter
+
+- The root handler increments the counter on every `GET /`.
+- The counter is persisted as plain text in `/data/visits`.
+- If the file is missing, the service starts from `0`.
+- If the file is malformed, empty, or negative, the service logs a warning and treats the value as `0`.
+
+## Local Docker Check
+
+For Lab 12, run the monitoring stack with a writable `/data` volume for the Python container and verify that:
+
+- repeated `GET /` calls increment the counter
+- `GET /visits` returns the current count
+- the counter survives a container restart because the backing file is persisted on the host
+
## Configuration
| Variable | Default | Description |
@@ -74,15 +90,16 @@ poetry install --with dev
poetry run pytest --cov=src --cov-report=term-missing
```
+The test suite covers:
+
+- `GET /` response schema and visits counter increment behavior
+- `GET /visits` bootstrap, persisted reads, and malformed-file fallback
+- `GET /health` successful response schema and types
+- `404` JSON error handling for unknown routes
+- `500` JSON error handling for simulated internal failures
+
## Linting
```bash
poetry run flake8 src tests
```
-
-Current test coverage includes:
-
-- `GET /` successful response schema and types
-- `GET /health` successful response schema and types
-- `404` JSON error handling for unknown routes
-- `500` JSON error handling for simulated internal failures
diff --git a/app_python/poetry.lock b/app_python/poetry.lock
index 5eb5476447..82248b12d8 100644
--- a/app_python/poetry.lock
+++ b/app_python/poetry.lock
@@ -26,153 +26,153 @@ files = [
[[package]]
name = "charset-normalizer"
-version = "3.4.6"
+version = "3.4.7"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
- {file = "charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6"},
- {file = "charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4"},
- {file = "charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb"},
- {file = "charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389"},
- {file = "charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f"},
- {file = "charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4"},
- {file = "charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-win32.whl", hash = "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae"},
- {file = "charset_normalizer-3.4.6-cp38-cp38-win_amd64.whl", hash = "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-win32.whl", hash = "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-win_amd64.whl", hash = "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8"},
- {file = "charset_normalizer-3.4.6-cp39-cp39-win_arm64.whl", hash = "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8"},
- {file = "charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69"},
- {file = "charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"},
+ {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"},
+ {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"},
]
[[package]]
name = "click"
-version = "8.3.1"
+version = "8.3.2"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
- {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"},
- {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"},
+ {file = "click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"},
+ {file = "click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5"},
]
[package.dependencies]
@@ -627,14 +627,14 @@ files = [
[[package]]
name = "pygments"
-version = "2.19.2"
+version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
groups = ["dev"]
files = [
- {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
- {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
+ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
+ {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
@@ -642,14 +642,14 @@ windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pytest"
-version = "9.0.2"
+version = "9.0.3"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
- {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
- {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
+ {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"},
+ {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"},
]
[package.dependencies]
@@ -684,14 +684,14 @@ testing = ["process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "requests"
-version = "2.33.0"
+version = "2.33.1"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
- {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"},
- {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"},
+ {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"},
+ {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"},
]
[package.dependencies]
@@ -702,7 +702,6 @@ urllib3 = ">=1.26,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
-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]]
@@ -725,14 +724,14 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]]
name = "werkzeug"
-version = "3.1.7"
+version = "3.1.8"
description = "The comprehensive WSGI web application library."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "werkzeug-3.1.7-py3-none-any.whl", hash = "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f"},
- {file = "werkzeug-3.1.7.tar.gz", hash = "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351"},
+ {file = "werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50"},
+ {file = "werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44"},
]
[package.dependencies]
diff --git a/app_python/pyproject.toml b/app_python/pyproject.toml
index ca312b1657..43c22bd9cd 100644
--- a/app_python/pyproject.toml
+++ b/app_python/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "devops-info-service"
-version = "1.10.0"
+version = "1.12.0"
description = ""
authors = [
{name = "LocalT0aster",email = "90502400+LocalT0aster@users.noreply.github.com"}
diff --git a/app_python/src/router.py b/app_python/src/router.py
index 35b32270f6..47174e44cd 100644
--- a/app_python/src/router.py
+++ b/app_python/src/router.py
@@ -6,7 +6,9 @@
import inspect
from multiprocessing import cpu_count
import platform
+from pathlib import Path
import socket
+from threading import Lock
from flask import jsonify, request
@@ -25,7 +27,9 @@
record_endpoint_call,
)
-__version__ = "1.10.0"
+__version__ = "1.12.0"
+VISITS_FILE = Path("/data/visits")
+_VISITS_LOCK = Lock()
def get_service_info() -> dict[str, str]:
@@ -128,9 +132,69 @@ def list_routes() -> list[dict[str, str]]:
return out
+def _read_visits_count() -> int:
+ """Read the current visits counter, defaulting to zero when missing."""
+ try:
+ raw_count = VISITS_FILE.read_text(encoding="utf-8").strip()
+ except FileNotFoundError:
+ return 0
+ except OSError as error:
+ logger.warning(
+ "failed to read visits counter",
+ extra={"path": str(VISITS_FILE), "error": str(error)},
+ )
+ return 0
+
+ if not raw_count:
+ logger.warning(
+ "invalid visits counter, resetting to zero",
+ extra={"path": str(VISITS_FILE), "value": ""},
+ )
+ return 0
+
+ try:
+ count = int(raw_count)
+ except ValueError:
+ logger.warning(
+ "invalid visits counter, resetting to zero",
+ extra={"path": str(VISITS_FILE), "value": raw_count},
+ )
+ return 0
+
+ if count < 0:
+ logger.warning(
+ "invalid visits counter, resetting to zero",
+ extra={"path": str(VISITS_FILE), "value": raw_count},
+ )
+ return 0
+
+ return count
+
+
+def _write_visits_count(count: int) -> None:
+ """Persist the visits counter to disk."""
+ VISITS_FILE.parent.mkdir(parents=True, exist_ok=True)
+ VISITS_FILE.write_text(f"{count}\n", encoding="utf-8")
+
+
+def get_visits_count() -> int:
+ """Return the current persisted visits count."""
+ with _VISITS_LOCK:
+ return _read_visits_count()
+
+
+def increment_visits_count() -> int:
+ """Increment and persist the visits counter."""
+ with _VISITS_LOCK:
+ count = _read_visits_count() + 1
+ _write_visits_count(count)
+ return count
+
+
@app.route("/")
def index():
"""Service information."""
+ increment_visits_count()
record_endpoint_call("/")
return jsonify(
{
@@ -143,6 +207,13 @@ def index():
)
+@app.route("/visits")
+def visits():
+ """Return the current persisted visits count."""
+ record_endpoint_call("/visits")
+ return jsonify({"visits": get_visits_count()})
+
+
@app.route("/health")
def health():
"""Health check."""
diff --git a/app_python/tests/conftest.py b/app_python/tests/conftest.py
index eef52e6a63..2319d53971 100644
--- a/app_python/tests/conftest.py
+++ b/app_python/tests/conftest.py
@@ -12,3 +12,9 @@ def client():
app.config.update(TESTING=True, PROPAGATE_EXCEPTIONS=False)
with app.test_client() as test_client:
yield test_client
+
+
+@pytest.fixture(autouse=True)
+def isolated_visits_file(tmp_path, monkeypatch):
+ """Route the visits counter to a per-test temporary file."""
+ monkeypatch.setattr(src.router, "VISITS_FILE", tmp_path / "visits")
diff --git a/app_python/tests/test_endpoints.py b/app_python/tests/test_endpoints.py
index 6299a7c1dd..08fe39fece 100644
--- a/app_python/tests/test_endpoints.py
+++ b/app_python/tests/test_endpoints.py
@@ -1,6 +1,7 @@
"""Unit tests for HTTP endpoints and error handling."""
from datetime import datetime
+from unittest.mock import Mock
import src.router as router
@@ -61,11 +62,71 @@ 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", "/visits") in route_index
assert ("GET", "/health") in route_index
assert ("GET", "/ready") in route_index
assert ("GET", "/metrics") in route_index
+def test_visits_defaults_to_zero_when_counter_file_is_missing(client, tmp_path, monkeypatch):
+ """GET /visits should bootstrap from zero when the counter file is absent."""
+ visits_file = tmp_path / "visits"
+ monkeypatch.setattr(router, "VISITS_FILE", visits_file)
+
+ response = client.get("/visits")
+
+ assert response.status_code == 200
+ assert response.get_json() == {"visits": 0}
+ assert not visits_file.exists()
+
+
+def test_index_increments_and_persists_visits_count(client, tmp_path, monkeypatch):
+ """GET / should increment the counter and persist the new value."""
+ visits_file = tmp_path / "visits"
+ monkeypatch.setattr(router, "VISITS_FILE", visits_file)
+
+ first_response = client.get("/")
+ second_response = client.get("/")
+ visits_response = client.get("/visits")
+
+ assert first_response.status_code == 200
+ assert second_response.status_code == 200
+ assert visits_response.status_code == 200
+ assert visits_response.get_json() == {"visits": 2}
+ assert visits_file.read_text(encoding="utf-8") == "2\n"
+
+
+def test_visits_returns_persisted_count(client, tmp_path, monkeypatch):
+ """GET /visits should return the current persisted counter value."""
+ visits_file = tmp_path / "visits"
+ visits_file.write_text("7\n", encoding="utf-8")
+ monkeypatch.setattr(router, "VISITS_FILE", visits_file)
+
+ response = client.get("/visits")
+
+ assert response.status_code == 200
+ assert response.get_json() == {"visits": 7}
+
+
+def test_visits_falls_back_to_zero_when_counter_file_is_malformed(
+ client,
+ tmp_path,
+ monkeypatch,
+):
+ """GET /visits should warn and recover when the counter file is malformed."""
+ visits_file = tmp_path / "visits"
+ visits_file.write_text("definitely-not-an-integer\n", encoding="utf-8")
+ warning_mock = Mock()
+ monkeypatch.setattr(router, "VISITS_FILE", visits_file)
+ monkeypatch.setattr(router.logger, "warning", warning_mock)
+
+ response = client.get("/visits")
+
+ assert response.status_code == 200
+ assert response.get_json() == {"visits": 0}
+ warning_mock.assert_called()
+
+
def test_health_returns_expected_json_structure_and_types(client):
"""GET /health should report healthy status and typed runtime metadata."""
response = client.get("/health")
From 59576140956d8b311834f62554b146e2e2847827 Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Fri, 10 Apr 2026 02:52:58 +0300
Subject: [PATCH 2/4] feat(app_go): add persistent visits endpoint
---
app_go/README.md | 15 ++++-
app_go/go.mod | 2 +-
app_go/go.sum | 4 +-
app_go/main.go | 95 ++++++++++++++++++++++++++++++--
app_go/main_test.go | 130 ++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 237 insertions(+), 9 deletions(-)
diff --git a/app_go/README.md b/app_go/README.md
index 1e8a5d867a..a2d551a7a3 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, health and readiness checks, Prometheus metrics, and structured JSON logs.
+Simple Go web service that exposes system/runtime details, a file-backed visits counter, health and readiness checks, Prometheus metrics, and structured JSON logs.
## Prerequisites
- Go 1.25+
@@ -20,10 +20,23 @@ HOST=127.0.0.1 PORT=8080 ./devops-info-service.out
## Endpoints
- `GET /` - service + system + runtime + request info
+- `GET /visits` - current visits counter stored in `/data/visits`
- `GET /health` - health check
- `GET /ready` - readiness check
- `GET /metrics` - Prometheus metrics exposition
+## Visits Counter
+- The root handler increments the counter on every `GET /`.
+- The counter is persisted as plain text in `/data/visits`.
+- If the file is missing, the service starts from `0`.
+- If the file is malformed, the service logs a warning and treats the value as `0`.
+
+## Local Docker Check
+For Lab 12, run the monitoring stack with a writable `/data` volume for the Go container and verify that:
+- repeated `GET /` calls increment the counter
+- `GET /visits` returns the current count
+- the counter survives a container restart because the backing file is persisted on the host
+
## Configuration
| Variable | Default | Description |
diff --git a/app_go/go.mod b/app_go/go.mod
index 1e5f872dad..dc064b6a3b 100644
--- a/app_go/go.mod
+++ b/app_go/go.mod
@@ -13,6 +13,6 @@ require (
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
- golang.org/x/sys v0.42.0 // indirect
+ golang.org/x/sys 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 2895e9cec2..e235955aab 100644
--- a/app_go/go.sum
+++ b/app_go/go.sum
@@ -28,8 +28,8 @@ 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/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
-golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
diff --git a/app_go/main.go b/app_go/main.go
index 7e482b4e80..31a9c48af4 100644
--- a/app_go/main.go
+++ b/app_go/main.go
@@ -8,6 +8,7 @@ import (
"net"
"net/http"
"os"
+ "path/filepath"
"runtime"
"strconv"
"strings"
@@ -20,7 +21,7 @@ import (
const (
serviceName = "devops-info-service"
- serviceVersion = "1.10.0"
+ serviceVersion = "1.12.0"
serviceDescription = "DevOps course info service"
serviceFramework = "Go net/http"
serviceLoggerName = "devops_info_service"
@@ -75,11 +76,17 @@ type StatusResponse struct {
UptimeSeconds int64 `json:"uptime_seconds"`
}
+type VisitsResponse struct {
+ Visits int `json:"visits"`
+}
+
var (
// startTime is used for uptime calculations.
- startTime = time.Now().UTC()
- logMu sync.Mutex
- logOutput io.Writer = os.Stdout
+ startTime = time.Now().UTC()
+ logMu sync.Mutex
+ visitsMu sync.Mutex
+ logOutput io.Writer = os.Stdout
+ visitsFilePath = "/data/visits"
// metricsRegistry only exposes service metrics, matching the Python app.
metricsRegistry = prometheus.NewRegistry()
httpRequestsTotal = prometheus.NewCounterVec(
@@ -123,6 +130,7 @@ var (
// endpoints is a static list used to mirror the Python app output.
endpoints = []EndpointInfo{
{Path: "/", Method: http.MethodGet, Description: "Service information."},
+ {Path: "/visits", Method: http.MethodGet, Description: "Visits counter."},
{Path: "/health", Method: http.MethodGet, Description: "Health check."},
{Path: "/ready", Method: http.MethodGet, Description: "Readiness check."},
{Path: "/metrics", Method: http.MethodGet, Description: "Prometheus metrics."},
@@ -260,6 +268,72 @@ func clientIP(r *http.Request) string {
return r.RemoteAddr
}
+func readVisitsCount() int {
+ data, err := os.ReadFile(visitsFilePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return 0
+ }
+
+ emitLog("WARNING", serviceLoggerName, "failed to read visits counter", map[string]any{
+ "error": err.Error(),
+ "path": visitsFilePath,
+ })
+ return 0
+ }
+
+ trimmed := strings.TrimSpace(string(data))
+ if trimmed == "" {
+ emitLog("WARNING", serviceLoggerName, "invalid visits counter, resetting to zero", map[string]any{
+ "path": visitsFilePath,
+ "value": "",
+ })
+ return 0
+ }
+
+ count, err := strconv.Atoi(trimmed)
+ if err != nil || count < 0 {
+ emitLog("WARNING", serviceLoggerName, "invalid visits counter, resetting to zero", map[string]any{
+ "path": visitsFilePath,
+ "value": trimmed,
+ })
+ return 0
+ }
+
+ return count
+}
+
+func writeVisitsCount(count int) error {
+ if err := os.MkdirAll(filepath.Dir(visitsFilePath), 0o755); err != nil {
+ return err
+ }
+
+ return os.WriteFile(visitsFilePath, []byte(fmt.Sprintf("%d\n", count)), 0o644)
+}
+
+func getVisitsCount() int {
+ visitsMu.Lock()
+ defer visitsMu.Unlock()
+
+ return readVisitsCount()
+}
+
+func incrementVisitsCount() int {
+ visitsMu.Lock()
+ defer visitsMu.Unlock()
+
+ count := readVisitsCount() + 1
+ if err := writeVisitsCount(count); err != nil {
+ emitLog("WARNING", serviceLoggerName, "failed to persist visits counter", map[string]any{
+ "error": err.Error(),
+ "path": visitsFilePath,
+ "value": count,
+ })
+ }
+
+ return count
+}
+
// listEndpoints returns the advertised endpoints for the root response.
func listEndpoints() []EndpointInfo {
return endpoints
@@ -267,7 +341,7 @@ func listEndpoints() []EndpointInfo {
func normalizeEndpointLabel(path string) string {
switch path {
- case "/", "/health", "/metrics", "/ready":
+ case "/", "/health", "/metrics", "/ready", "/visits":
return path
default:
return "unmatched"
@@ -336,6 +410,7 @@ func queryString(r *http.Request) string {
// mainHandler serves GET /.
func mainHandler(w http.ResponseWriter, r *http.Request) {
recordEndpointCall("/")
+ incrementVisitsCount()
payload := RootResponse{
Service: getServiceInfo(),
System: getSystemInfo(),
@@ -347,6 +422,14 @@ func mainHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, payload)
}
+// visitsHandler serves GET /visits.
+func visitsHandler(w http.ResponseWriter, r *http.Request) {
+ recordEndpointCall("/visits")
+ writeJSON(w, http.StatusOK, VisitsResponse{
+ Visits: getVisitsCount(),
+ })
+}
+
// healthHandler serves GET /health.
func healthHandler(w http.ResponseWriter, r *http.Request) {
recordEndpointCall("/health")
@@ -395,6 +478,8 @@ func router(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/" && r.Method == http.MethodGet:
mainHandler(w, r)
+ case r.URL.Path == "/visits" && r.Method == http.MethodGet:
+ visitsHandler(w, r)
case r.URL.Path == "/health" && r.Method == http.MethodGet:
healthHandler(w, r)
case r.URL.Path == "/metrics" && r.Method == http.MethodGet:
diff --git a/app_go/main_test.go b/app_go/main_test.go
index 32d1784ed3..dbb7d9d79d 100644
--- a/app_go/main_test.go
+++ b/app_go/main_test.go
@@ -6,6 +6,8 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"strconv"
"strings"
"testing"
@@ -81,6 +83,18 @@ func performRequest(handler http.Handler, method, path string) *httptest.Respons
return recorder
}
+func withTempVisitsFile(t *testing.T) string {
+ t.Helper()
+
+ oldPath := visitsFilePath
+ visitsFilePath = filepath.Join(t.TempDir(), "visits")
+ t.Cleanup(func() {
+ visitsFilePath = oldPath
+ })
+
+ return visitsFilePath
+}
+
func metricValue(metricsText, sampleName string, labels map[string]string) (float64, bool) {
for _, line := range strings.Split(metricsText, "\n") {
line = strings.TrimSpace(line)
@@ -194,6 +208,57 @@ func TestIndexReturnsExpectedJSONStructureAndTypes(t *testing.T) {
for _, route := range []string{
http.MethodGet + " /",
+ http.MethodGet + " /visits",
+ http.MethodGet + " /health",
+ http.MethodGet + " /ready",
+ http.MethodGet + " /metrics",
+ } {
+ if !routeIndex[route] {
+ t.Fatalf("expected endpoint %q to be listed", route)
+ }
+ }
+}
+
+func TestVisitsEndpointDefaultsToZeroWhenFileMissing(t *testing.T) {
+ restore := captureLogOutput(io.Discard)
+ defer restore()
+
+ withTempVisitsFile(t)
+
+ recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/visits")
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
+ }
+
+ payload := decodeJSONResponse[VisitsResponse](t, recorder)
+ if payload.Visits != 0 {
+ t.Fatalf("expected visits to default to 0, got %d", payload.Visits)
+ }
+
+ if _, err := os.Stat(visitsFilePath); !os.IsNotExist(err) {
+ t.Fatalf("expected visits file to remain absent, got err=%v", err)
+ }
+}
+
+func TestRootIncrementsVisitsCounterAndPersistsFile(t *testing.T) {
+ restore := captureLogOutput(io.Discard)
+ defer restore()
+
+ withTempVisitsFile(t)
+
+ first := performRequest(http.HandlerFunc(router), http.MethodGet, "/")
+ if first.Code != http.StatusOK {
+ t.Fatalf("expected status %d, got %d", http.StatusOK, first.Code)
+ }
+
+ payload := decodeJSONResponse[RootResponse](t, first)
+ routeIndex := map[string]bool{}
+ for _, endpoint := range payload.Endpoints {
+ routeIndex[endpoint.Method+" "+endpoint.Path] = true
+ }
+ for _, route := range []string{
+ http.MethodGet + " /",
+ http.MethodGet + " /visits",
http.MethodGet + " /health",
http.MethodGet + " /ready",
http.MethodGet + " /metrics",
@@ -202,6 +267,71 @@ func TestIndexReturnsExpectedJSONStructureAndTypes(t *testing.T) {
t.Fatalf("expected endpoint %q to be listed", route)
}
}
+
+ data, err := os.ReadFile(visitsFilePath)
+ if err != nil {
+ t.Fatalf("expected visits file to be created: %v", err)
+ }
+ if got := strings.TrimSpace(string(data)); got != "1" {
+ t.Fatalf("expected visits file to contain 1 after first root request, got %q", got)
+ }
+
+ second := performRequest(http.HandlerFunc(router), http.MethodGet, "/")
+ if second.Code != http.StatusOK {
+ t.Fatalf("expected status %d, got %d", http.StatusOK, second.Code)
+ }
+
+ data, err = os.ReadFile(visitsFilePath)
+ if err != nil {
+ t.Fatalf("expected visits file to remain readable: %v", err)
+ }
+ if got := strings.TrimSpace(string(data)); got != "2" {
+ t.Fatalf("expected visits file to contain 2 after second root request, got %q", got)
+ }
+
+ visits := performRequest(http.HandlerFunc(router), http.MethodGet, "/visits")
+ if visits.Code != http.StatusOK {
+ t.Fatalf("expected status %d, got %d", http.StatusOK, visits.Code)
+ }
+
+ count := decodeJSONResponse[VisitsResponse](t, visits)
+ if count.Visits != 2 {
+ t.Fatalf("expected visits endpoint to report 2, got %d", count.Visits)
+ }
+}
+
+func TestVisitsEndpointFallsBackToZeroForMalformedFile(t *testing.T) {
+ restore := captureLogOutput(io.Discard)
+ defer restore()
+
+ withTempVisitsFile(t)
+
+ if err := os.WriteFile(visitsFilePath, []byte("broken"), 0o644); err != nil {
+ t.Fatalf("failed to seed malformed visits file: %v", err)
+ }
+
+ recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/visits")
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
+ }
+
+ payload := decodeJSONResponse[VisitsResponse](t, recorder)
+ if payload.Visits != 0 {
+ t.Fatalf("expected malformed counter to fall back to 0, got %d", payload.Visits)
+ }
+
+ after := performRequest(http.HandlerFunc(router), http.MethodGet, "/")
+ if after.Code != http.StatusOK {
+ t.Fatalf("expected status %d, got %d", http.StatusOK, after.Code)
+ }
+
+ data, err := os.ReadFile(visitsFilePath)
+ if err != nil {
+ t.Fatalf("expected visits file to be repaired by root request: %v", err)
+ }
+ if got := strings.TrimSpace(string(data)); got != "1" {
+ t.Fatalf("expected repaired visits file to contain 1, got %q", got)
+ }
}
func TestHealthReturnsExpectedJSONStructureAndTypes(t *testing.T) {
From ce6853d65ce019bfab187142b852f0738503b61f Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Fri, 10 Apr 2026 02:58:14 +0300
Subject: [PATCH 3/4] feat(lab12): add configmaps, pvc
---
k8s/devops-app-py/Chart.yaml | 4 +--
k8s/devops-app-py/files/config.json | 17 +++++++++
k8s/devops-app-py/templates/_helpers.tpl | 40 +++++++++++++++++++++
k8s/devops-app-py/templates/configmap.yaml | 24 +++++++++++++
k8s/devops-app-py/templates/deployment.yaml | 39 ++++++++++++++++++--
k8s/devops-app-py/templates/pvc.yaml | 17 +++++++++
k8s/devops-app-py/values-dev.yaml | 14 +++++---
k8s/devops-app-py/values-prod.yaml | 16 ++++++---
k8s/devops-app-py/values.yaml | 31 ++++++++++++++--
monitoring/.gitignore | 1 +
monitoring/docker-compose.yml | 8 +++--
11 files changed, 194 insertions(+), 17 deletions(-)
create mode 100644 k8s/devops-app-py/files/config.json
create mode 100644 k8s/devops-app-py/templates/configmap.yaml
create mode 100644 k8s/devops-app-py/templates/pvc.yaml
diff --git a/k8s/devops-app-py/Chart.yaml b/k8s/devops-app-py/Chart.yaml
index 9c6a6c21c2..8aec2f0141 100644
--- a/k8s/devops-app-py/Chart.yaml
+++ b/k8s/devops-app-py/Chart.yaml
@@ -2,8 +2,8 @@ apiVersion: v2
name: devops-app-py
description: Helm chart for the DevOps Core Python application
type: application
-version: 0.3.0
-appVersion: "1.9"
+version: 0.4.0
+appVersion: "1.12.0"
keywords:
- python
- flask
diff --git a/k8s/devops-app-py/files/config.json b/k8s/devops-app-py/files/config.json
new file mode 100644
index 0000000000..6cf32ae23e
--- /dev/null
+++ b/k8s/devops-app-py/files/config.json
@@ -0,0 +1,17 @@
+{
+ "application": {
+ "name": {{ .Values.config.file.appName | quote }},
+ "environment": {{ .Values.config.file.environment | quote }},
+ "version": {{ (.Values.image.tag | default .Chart.AppVersion) | quote }}
+ },
+ "featureFlags": {
+ "visitsCounter": {{ .Values.config.file.featureFlags.visitsCounter }},
+ "metrics": {{ .Values.config.file.featureFlags.metrics }},
+ "configReloadDemo": {{ .Values.config.file.featureFlags.configReloadDemo }}
+ },
+ "settings": {
+ "configPath": {{ printf "%s/config.json" .Values.config.mountPath | quote }},
+ "visitsFile": {{ printf "%s/visits" .Values.persistence.mountPath | quote }},
+ "reloadStrategy": "checksum-rollout"
+ }
+}
diff --git a/k8s/devops-app-py/templates/_helpers.tpl b/k8s/devops-app-py/templates/_helpers.tpl
index 68a3b20d88..c86a8d9ab9 100644
--- a/k8s/devops-app-py/templates/_helpers.tpl
+++ b/k8s/devops-app-py/templates/_helpers.tpl
@@ -65,6 +65,27 @@ Create the secret name.
{{- printf "%s-secret" (include "devops-app-py.fullname" .) | trunc 63 | trimSuffix "-" }}
{{- end }}
+{{/*
+Create the file ConfigMap name.
+*/}}
+{{- define "devops-app-py.fileConfigMapName" -}}
+{{- printf "%s-config" (include "devops-app-py.fullname" .) | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create the env ConfigMap name.
+*/}}
+{{- define "devops-app-py.envConfigMapName" -}}
+{{- printf "%s-env" (include "devops-app-py.fullname" .) | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create the PVC name.
+*/}}
+{{- define "devops-app-py.pvcName" -}}
+{{- printf "%s-data" (include "devops-app-py.fullname" .) | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
{{/*
Create the service account name.
*/}}
@@ -86,6 +107,25 @@ Render non-secret environment variables.
{{- end }}
{{- end }}
+{{/*
+Render the chart-managed config.json file.
+*/}}
+{{- define "devops-app-py.renderedConfigJson" -}}
+{{- tpl (.Files.Get "files/config.json") . -}}
+{{- end }}
+
+{{/*
+Render pod checksum annotations for config-driven rollouts.
+*/}}
+{{- define "devops-app-py.configChecksums" -}}
+{{- if .Values.config.file.enabled }}
+checksum/config-file: {{ include "devops-app-py.renderedConfigJson" . | sha256sum | quote }}
+{{- end }}
+{{- if .Values.config.env.enabled }}
+checksum/config-env: {{ toJson .Values.config.env.data | sha256sum | quote }}
+{{- end }}
+{{- end }}
+
{{/*
Render Vault injector annotations.
*/}}
diff --git a/k8s/devops-app-py/templates/configmap.yaml b/k8s/devops-app-py/templates/configmap.yaml
new file mode 100644
index 0000000000..a7026b28bb
--- /dev/null
+++ b/k8s/devops-app-py/templates/configmap.yaml
@@ -0,0 +1,24 @@
+{{- if .Values.config.file.enabled }}
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ include "devops-app-py.fileConfigMapName" . }}
+ labels:
+ {{- include "devops-app-py.labels" . | nindent 4 }}
+data:
+ config.json: |-
+{{ include "devops-app-py.renderedConfigJson" . | indent 4 }}
+{{- end }}
+{{- if and .Values.config.file.enabled .Values.config.env.enabled }}
+---
+{{- end }}
+{{- if .Values.config.env.enabled }}
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ include "devops-app-py.envConfigMapName" . }}
+ labels:
+ {{- include "devops-app-py.labels" . | nindent 4 }}
+data:
+ {{- toYaml .Values.config.env.data | nindent 2 }}
+{{- end }}
diff --git a/k8s/devops-app-py/templates/deployment.yaml b/k8s/devops-app-py/templates/deployment.yaml
index 607c604237..d099e3a42c 100644
--- a/k8s/devops-app-py/templates/deployment.yaml
+++ b/k8s/devops-app-py/templates/deployment.yaml
@@ -2,6 +2,7 @@ apiVersion: apps/v1
kind: Deployment
{{- $envVars := include "devops-app-py.envVars" . | trim }}
{{- $vaultAnnotations := include "devops-app-py.vaultAnnotations" . | trim }}
+{{- $configChecksums := include "devops-app-py.configChecksums" . | trim }}
metadata:
name: {{ include "devops-app-py.fullname" . }}
labels:
@@ -19,11 +20,14 @@ spec:
{{- include "devops-app-py.selectorLabels" . | nindent 6 }}
template:
metadata:
- {{- if or $vaultAnnotations .Values.podAnnotations }}
+ {{- if or $vaultAnnotations $configChecksums .Values.podAnnotations }}
annotations:
{{- if $vaultAnnotations }}
{{- $vaultAnnotations | nindent 8 }}
{{- end }}
+ {{- if $configChecksums }}
+ {{- $configChecksums | nindent 8 }}
+ {{- end }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -44,10 +48,28 @@ spec:
- name: http
containerPort: {{ .Values.containerPort }}
protocol: TCP
- {{- if .Values.secrets.enabled }}
+ {{- if or .Values.config.file.enabled .Values.persistence.enabled }}
+ volumeMounts:
+ {{- if .Values.config.file.enabled }}
+ - name: config-volume
+ mountPath: {{ .Values.config.mountPath | quote }}
+ readOnly: true
+ {{- end }}
+ {{- if .Values.persistence.enabled }}
+ - name: data-volume
+ mountPath: {{ .Values.persistence.mountPath | quote }}
+ {{- end }}
+ {{- end }}
+ {{- if or .Values.config.env.enabled .Values.secrets.enabled }}
envFrom:
+ {{- if .Values.config.env.enabled }}
+ - configMapRef:
+ name: {{ include "devops-app-py.envConfigMapName" . }}
+ {{- end }}
+ {{- if .Values.secrets.enabled }}
- secretRef:
name: {{ include "devops-app-py.secretName" . }}
+ {{- end }}
{{- end }}
{{- if $envVars }}
env:
@@ -65,3 +87,16 @@ spec:
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
+ {{- if or .Values.config.file.enabled .Values.persistence.enabled }}
+ volumes:
+ {{- if .Values.config.file.enabled }}
+ - name: config-volume
+ configMap:
+ name: {{ include "devops-app-py.fileConfigMapName" . }}
+ {{- end }}
+ {{- if .Values.persistence.enabled }}
+ - name: data-volume
+ persistentVolumeClaim:
+ claimName: {{ include "devops-app-py.pvcName" . }}
+ {{- end }}
+ {{- end }}
diff --git a/k8s/devops-app-py/templates/pvc.yaml b/k8s/devops-app-py/templates/pvc.yaml
new file mode 100644
index 0000000000..5f817de35e
--- /dev/null
+++ b/k8s/devops-app-py/templates/pvc.yaml
@@ -0,0 +1,17 @@
+{{- if .Values.persistence.enabled }}
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: {{ include "devops-app-py.pvcName" . }}
+ labels:
+ {{- include "devops-app-py.labels" . | nindent 4 }}
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: {{ .Values.persistence.size }}
+ {{- if .Values.persistence.storageClass }}
+ storageClassName: {{ .Values.persistence.storageClass | quote }}
+ {{- end }}
+{{- end }}
diff --git a/k8s/devops-app-py/values-dev.yaml b/k8s/devops-app-py/values-dev.yaml
index f2d401a344..234178c728 100644
--- a/k8s/devops-app-py/values-dev.yaml
+++ b/k8s/devops-app-py/values-dev.yaml
@@ -1,7 +1,7 @@
replicaCount: 1
image:
- tag: "1.9-dev"
+ tag: "1.12-dev"
deployment:
revisionHistoryLimit: 2
@@ -11,8 +11,14 @@ env:
value: "0.0.0.0"
- name: PORT
value: "5000"
- - name: APP_ENV
- value: "development"
+
+config:
+ file:
+ environment: "development"
+ env:
+ data:
+ APP_ENV: "development"
+ LOG_LEVEL: "debug"
podLabels:
environment: dev
@@ -21,7 +27,7 @@ service:
type: NodePort
port: 80
targetPort: 5000
- nodePort: 30081
+ nodePort: 30082
resources:
requests:
diff --git a/k8s/devops-app-py/values-prod.yaml b/k8s/devops-app-py/values-prod.yaml
index cca4fee171..1feebd8413 100644
--- a/k8s/devops-app-py/values-prod.yaml
+++ b/k8s/devops-app-py/values-prod.yaml
@@ -1,7 +1,7 @@
-replicaCount: 3
+replicaCount: 1
image:
- tag: "1.9"
+ tag: "1.12"
deployment:
revisionHistoryLimit: 10
@@ -11,8 +11,16 @@ env:
value: "0.0.0.0"
- name: PORT
value: "5000"
- - name: APP_ENV
- value: "production"
+
+config:
+ file:
+ environment: "production"
+ featureFlags:
+ configReloadDemo: false
+ env:
+ data:
+ APP_ENV: "production"
+ LOG_LEVEL: "info"
podLabels:
environment: prod
diff --git a/k8s/devops-app-py/values.yaml b/k8s/devops-app-py/values.yaml
index 59d2f5cd84..253a63eb46 100644
--- a/k8s/devops-app-py/values.yaml
+++ b/k8s/devops-app-py/values.yaml
@@ -1,9 +1,9 @@
-replicaCount: 5
+replicaCount: 1
partOf: devops-core-s26
image:
repository: localt0aster/devops-app-py
- tag: "1.9"
+ tag: "1.12-dev"
pullPolicy: IfNotPresent
containerPort: 5000
@@ -26,6 +26,31 @@ env:
- name: PORT
value: "5000"
+config:
+ mountPath: /config
+ file:
+ enabled: true
+ appName: "devops-info-service"
+ environment: "development"
+ featureFlags:
+ visitsCounter: true
+ metrics: true
+ configReloadDemo: true
+ env:
+ enabled: true
+ data:
+ APP_NAME: "devops-info-service"
+ APP_ENV: "development"
+ APP_CONFIG_PATH: "/config/config.json"
+ APP_VISITS_PATH: "/data/visits"
+ LOG_LEVEL: "info"
+
+persistence:
+ enabled: true
+ mountPath: /data
+ size: 100Mi
+ storageClass: ""
+
secrets:
enabled: true
stringData:
@@ -46,7 +71,7 @@ service:
type: NodePort
port: 80
targetPort: 5000
- nodePort: 30080
+ nodePort: 30082
resources:
requests:
diff --git a/monitoring/.gitignore b/monitoring/.gitignore
index 03bd4129be..d965490406 100644
--- a/monitoring/.gitignore
+++ b/monitoring/.gitignore
@@ -1 +1,2 @@
*.env
+data/
diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml
index 17e5da4e4d..15acfd1bb4 100644
--- a/monitoring/docker-compose.yml
+++ b/monitoring/docker-compose.yml
@@ -133,12 +133,14 @@ services:
restart: unless-stopped
app-python:
- image: localt0aster/devops-app-py:1.8
+ image: localt0aster/devops-app-py:1.12-dev
environment:
HOST: "0.0.0.0"
PORT: "8000"
ports:
- "8000:8000"
+ volumes:
+ - ./data/app-python:/data
labels:
logging: "promtail"
app: "devops-python"
@@ -163,7 +165,7 @@ services:
restart: unless-stopped
app-go:
- image: localt0aster/devops-app-go:1.7.9a42ee5
+ image: localt0aster/devops-app-go:1.12-dev
# Re-enable local builds if Docker networking breaks behind the tun/VPN setup.
# build:
# context: ../app_go
@@ -173,6 +175,8 @@ services:
PORT: "8001"
ports:
- "8001:8001"
+ volumes:
+ - ./data/app-go:/data
labels:
logging: "promtail"
app: "devops-go"
From 9542023baddf5abf0c4eb9b8a6284583b136f9cc Mon Sep 17 00:00:00 2001
From: LocalT0aster <90502400+LocalT0aster@users.noreply.github.com>
Date: Fri, 10 Apr 2026 03:11:36 +0300
Subject: [PATCH 4/4] docs(k8s): add lab12 report
---
k8s/CONFIGMAPS.md | 16 ++
k8s/README.md | 2 +
k8s/docs/LAB12.md | 519 ++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 537 insertions(+)
create mode 100644 k8s/CONFIGMAPS.md
create mode 100644 k8s/docs/LAB12.md
diff --git a/k8s/CONFIGMAPS.md b/k8s/CONFIGMAPS.md
new file mode 100644
index 0000000000..3343b2c6a7
--- /dev/null
+++ b/k8s/CONFIGMAPS.md
@@ -0,0 +1,16 @@
+# ConfigMap Notes
+
+This file exists to satisfy the Lab 12 requirement for a dedicated ConfigMap document without flattening the Kubernetes module back into one large documentation directory.
+
+## Lab 12 Documentation
+
+The full Lab 12 write-up, command transcripts, Docker persistence proof, Kubernetes verification, and hot-reload notes are kept in [docs/LAB12.md](docs/LAB12.md).
+
+## Why This Structure Is Better
+
+- `k8s/README.md` stays short and usable as the module entry point.
+- `k8s/docs/LAB09.md`, [docs/LAB10.md](docs/LAB10.md), [docs/LAB11.md](docs/LAB11.md), and [docs/LAB12.md](docs/LAB12.md) keep each Kubernetes lab self-contained.
+- Raw manifests, Helm chart files, and documentation stay separated, which makes the implementation files easier to navigate.
+- `k8s/CONFIGMAPS.md` provides the compatibility filename the lab expects while the actual report remains in the `docs/` hierarchy.
+
+In short, `CONFIGMAPS.md` is the compatibility layer, and `k8s/docs/` remains the maintainable long-term structure.
diff --git a/k8s/README.md b/k8s/README.md
index 805a36dbcd..2ad36987ad 100644
--- a/k8s/README.md
+++ b/k8s/README.md
@@ -13,6 +13,8 @@ The main deployment assets are:
- [Helm Notes](HELM.md)
- [Secrets Notes](SECRETS.md)
+- [ConfigMap Notes](CONFIGMAPS.md)
- [Lab 09 - Kubernetes Basics](docs/LAB09.md)
- [Lab 10 - Helm Package Manager](docs/LAB10.md)
- [Lab 11 - Kubernetes Secrets and Vault](docs/LAB11.md)
+- [Lab 12 - ConfigMaps and Persistent Volumes](docs/LAB12.md)
diff --git a/k8s/docs/LAB12.md b/k8s/docs/LAB12.md
new file mode 100644
index 0000000000..8660a969c2
--- /dev/null
+++ b/k8s/docs/LAB12.md
@@ -0,0 +1,519 @@
+# Kubernetes Lab 12 - ConfigMaps and Persistent Volumes
+
+I reused the existing Docker-backed `minikube` cluster instead of tearing it down. The starting state already contained the Lab 11 app release and Vault, so this lab was added on top of that environment with a new Helm release name, `lab12-devops-app-py`, to avoid clobbering the earlier work. All usernames and passwords are redacted in this write-up.
+
+## Current Cluster Context
+
+
+kubectl config current-context, kubectl cluster-info, kubectl get nodes -o wide, kubectl get storageclass, helm list -A
+
+```text
+$ kubectl config current-context
+minikube
+$ 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 52m v1.35.1 192.168.49.2 Debian GNU/Linux 12 (bookworm) 6.19.11-1-cachyos docker://29.2.1
+$ kubectl get storageclass
+NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
+standard (default) k8s.io/minikube-hostpath Delete Immediate false 52m
+$ helm list -A
+NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION
+lab11-devops-app-py default 2 2026-04-10 02:04:56.679295263 +0300 +03 deployed devops-app-py-0.3.0 1.9
+vault vault 1 2026-04-10 02:02:00.558749873 +0300 +03 deployed vault-0.32.0 1.21.2
+```
+
+
+
+## Task 1 - Application Persistence Upgrade
+
+I implemented the same file-backed visits counter in both `app_python` and `app_go`. Both services now store the counter in `/data/visits`, increment it on every `GET /`, expose `GET /visits`, default to `0` when the file is missing, and recover from malformed content by treating it as `0`. The Python runtime version and `pyproject.toml` version both moved to `1.12.0`, and the Go runtime version moved to `1.12.0`.
+
+
+./.venv/bin/pytest, go test ./..., and separate app commits
+
+```bash
+$ ./.venv/bin/pytest
+============================= test session starts ==============================
+platform linux -- Python 3.14.3, pytest-9.0.2, pluggy-1.6.0
+rootdir: /home/t0ast/Repos/DevOps-Core-S26/app_python
+configfile: pyproject.toml
+plugins: anyio-4.12.1, cov-7.1.0
+collected 19 items
+
+tests/test_endpoints.py ........... [ 57%]
+tests/test_logging_utils.py . [ 63%]
+tests/test_metrics.py .. [ 73%]
+tests/test_unit_helpers.py ..... [100%]
+
+============================== 19 passed in 0.11s ==============================
+
+$ go test ./...
+ok example.com/devops-info-service 0.005s
+
+$ git log --oneline -2
+3ebf11e feat(app_go): add persistent visits endpoint
+ceaf67d feat(app_python): add persistent visits endpoint
+```
+
+
+
+
+docker compose -f monitoring/docker-compose.yml config | sed -n "/app-go:/,/grafana:/p"
+
+```text
+$ docker compose -f monitoring/docker-compose.yml config | sed -n /app-go:/,/grafana:/p
+ app-go:
+ environment:
+ HOST: 0.0.0.0
+ PORT: "8001"
+ image: localt0aster/devops-app-go:1.12-dev
+ ports:
+ - mode: ingress
+ target: 8001
+ published: "8001"
+ protocol: tcp
+ volumes:
+ - type: bind
+ source: /home/t0ast/Repos/DevOps-Core-S26/monitoring/data/app-go
+ target: /data
+ bind: {}
+ app-python:
+ environment:
+ HOST: 0.0.0.0
+ PORT: "8000"
+ image: localt0aster/devops-app-py:1.12-dev
+ ports:
+ - mode: ingress
+ target: 8000
+ published: "8000"
+ protocol: tcp
+ volumes:
+ - type: bind
+ source: /home/t0ast/Repos/DevOps-Core-S26/monitoring/data/app-python
+ target: /data
+ bind: {}
+```
+
+
+
+
+docker compose up and local visits persistence proof for both apps
+
+```bash
+$ docker compose -f monitoring/docker-compose.yml up -d --pull always app-python app-go app-go-healthcheck
+...
+$ docker compose -f monitoring/docker-compose.yml ps app-python app-go app-go-healthcheck
+NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
+monitoring-app-go-1 localt0aster/devops-app-go:1.12-dev "/devops-info-servic…" app-go 1 second ago Up Less than a second 0.0.0.0:8001->8001/tcp, [::]:8001->8001/tcp
+monitoring-app-go-healthcheck-1 curlimages/curl:8.18.0 "/entrypoint.sh sh -…" app-go-healthcheck 1 second ago Up Less than a second (health: starting)
+monitoring-app-python-1 localt0aster/devops-app-py:1.12-dev "sh -c 'gunicorn --c…" app-python 1 second ago Up Less than a second (health: starting) 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp
+
+$ curl -sS http://127.0.0.1:8000/visits | jq .
+{
+ "visits": 0
+}
+$ curl -sS http://127.0.0.1:8000/ >/dev/null
+$ curl -sS http://127.0.0.1:8000/ >/dev/null
+$ curl -sS http://127.0.0.1:8000/visits | jq .
+{
+ "visits": 2
+}
+$ cat monitoring/data/app-python/visits
+2
+
+$ curl -sS http://127.0.0.1:8001/visits | jq .
+{
+ "visits": 0
+}
+$ curl -sS http://127.0.0.1:8001/ >/dev/null
+$ curl -sS http://127.0.0.1:8001/ >/dev/null
+$ curl -sS http://127.0.0.1:8001/visits | jq .
+{
+ "visits": 2
+}
+$ cat monitoring/data/app-go/visits
+2
+
+$ docker compose -f monitoring/docker-compose.yml restart app-python app-go
+...
+$ curl -sS http://127.0.0.1:8000/visits | jq .
+{
+ "visits": 2
+}
+$ curl -sS http://127.0.0.1:8001/visits | jq .
+{
+ "visits": 2
+}
+$ cat monitoring/data/app-python/visits
+2
+$ cat monitoring/data/app-go/visits
+2
+```
+
+
+
+## Task 2 - ConfigMaps
+
+I extended the existing Helm chart instead of writing one-off manifests. The chart now contains:
+
+- `files/config.json` as the file-backed application config source
+- `templates/configmap.yaml` rendering both a file ConfigMap and an env ConfigMap
+- `templates/pvc.yaml` for the visits counter volume
+- checksum annotations on the Pod template so chart-managed config changes trigger a rollout
+
+I also changed the chart defaults for Lab 12 correctness: `replicaCount` is now `1`, the chart version is `0.4.0`, the app version is `1.12.0`, and the dev NodePort moved to `30082` so it does not collide with the existing Lab 11 release on `30081`.
+
+
+helm lint and rendered manifest excerpts
+
+```bash
+$ helm lint k8s/devops-app-py
+==> Linting k8s/devops-app-py
+[INFO] Chart.yaml: icon is recommended
+
+1 chart(s) linted, 0 chart(s) failed
+```
+
+```yaml
+# Source: devops-app-py/templates/configmap.yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: lab12-devops-app-py-config
+data:
+ config.json: |-
+ {
+ "application": {
+ "name": "devops-info-service",
+ "environment": "development",
+ "version": "1.12-dev"
+ },
+ "featureFlags": {
+ "visitsCounter": true,
+ "metrics": true,
+ "configReloadDemo": true
+ },
+ "settings": {
+ "configPath": "/config/config.json",
+ "visitsFile": "/data/visits",
+ "reloadStrategy": "checksum-rollout"
+ }
+ }
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: lab12-devops-app-py-env
+data:
+ APP_CONFIG_PATH: /config/config.json
+ APP_ENV: development
+ APP_NAME: devops-info-service
+ APP_VISITS_PATH: /data/visits
+ LOG_LEVEL: debug
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: lab12-devops-app-py-data
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 100Mi
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: lab12-devops-app-py
+spec:
+ replicas: 1
+ template:
+ metadata:
+ annotations:
+ checksum/config-file: "75d068a2b686d100b01ef7eb95c683ff78dc01f7103131c74502cd3dba657e95"
+ checksum/config-env: "ade0526aff038f694a130dfce92e2748879ea4cd4e9a802b692762639b4851bf"
+ spec:
+ containers:
+ - name: devops-app-py
+ image: "localt0aster/devops-app-py:1.12-dev"
+ volumeMounts:
+ - name: config-volume
+ mountPath: "/config"
+ readOnly: true
+ - name: data-volume
+ mountPath: "/data"
+ envFrom:
+ - configMapRef:
+ name: lab12-devops-app-py-env
+ - secretRef:
+ name: lab12-devops-app-py-secret
+ volumes:
+ - name: config-volume
+ configMap:
+ name: lab12-devops-app-py-config
+ - name: data-volume
+ persistentVolumeClaim:
+ claimName: lab12-devops-app-py-data
+```
+
+
+
+## Task 3 - Persistent Volumes
+
+I installed the updated chart as a new release named `lab12-devops-app-py`, verified the rendered ConfigMaps and PVC in-cluster, then proved that the application could read `/config/config.json`, receive the env ConfigMap via `envFrom`, write `/data/visits`, and survive a Pod replacement without losing the counter.
+
+One useful operational detail from `kubectl describe pod` is that Kubernetes shows the env var sources as `ConfigMap` and `Secret` references instead of dumping the actual values. That keeps Pod inspection safer while still proving where the data comes from.
+
+
+helm upgrade --install lab12-devops-app-py ... and initial resource state
+
+```bash
+$ helm upgrade --install lab12-devops-app-py k8s/devops-app-py -n default -f k8s/devops-app-py/values-dev.yaml --wait --wait-for-jobs --timeout 300s
+Release "lab12-devops-app-py" does not exist. Installing it now.
+NAME: lab12-devops-app-py
+LAST DEPLOYED: Fri Apr 10 03:02:03 2026
+NAMESPACE: default
+STATUS: deployed
+REVISION: 1
+DESCRIPTION: Install complete
+```
+
+```text
+$ kubectl get deploy,svc,pod,configmap,pvc -n default -l app.kubernetes.io/instance=lab12-devops-app-py -o wide
+NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR
+deployment.apps/lab12-devops-app-py 1/1 1 1 61s devops-app-py localt0aster/devops-app-py:1.12-dev app.kubernetes.io/instance=lab12-devops-app-py,app.kubernetes.io/name=devops-app-py
+
+NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR
+service/lab12-devops-app-py-service NodePort 10.101.70.46 80:30082/TCP 61s app.kubernetes.io/instance=lab12-devops-app-py,app.kubernetes.io/name=devops-app-py
+
+NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
+pod/lab12-devops-app-py-5f6df94f6d-5wxtq 1/1 Running 0 61s 10.244.0.10 minikube
+
+NAME DATA AGE
+configmap/lab12-devops-app-py-config 1 61s
+configmap/lab12-devops-app-py-env 5 61s
+
+NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE VOLUMEMODE
+persistentvolumeclaim/lab12-devops-app-py-data Bound pvc-d04d328c-f6f3-44f7-8347-d8724a16b744 100Mi RWO standard 61s Filesystem
+```
+
+
+
+
+kubectl exec for mounted /config/config.json, env vars, and Pod description
+
+```bash
+$ kubectl exec -n default lab12-devops-app-py-5f6df94f6d-5wxtq -- cat /config/config.json | jq .
+{
+ "application": {
+ "name": "devops-info-service",
+ "environment": "development",
+ "version": "1.12-dev"
+ },
+ "featureFlags": {
+ "visitsCounter": true,
+ "metrics": true,
+ "configReloadDemo": true
+ },
+ "settings": {
+ "configPath": "/config/config.json",
+ "visitsFile": "/data/visits",
+ "reloadStrategy": "checksum-rollout"
+ }
+}
+
+$ kubectl exec -n default lab12-devops-app-py-5f6df94f6d-5wxtq -- printenv | grep -E "^(APP_|LOG_LEVEL)"
+APP_ENV=development
+LOG_LEVEL=debug
+APP_CONFIG_PATH=/config/config.json
+APP_NAME=devops-info-service
+APP_VISITS_PATH=/data/visits
+APP_PASSWORD=[REDACTED]
+APP_USERNAME=[REDACTED]
+```
+
+```text
+$ kubectl describe pod -n default lab12-devops-app-py-5f6df94f6d-bkwfx
+...
+Environment Variables from:
+ lab12-devops-app-py-env ConfigMap Optional: false
+ lab12-devops-app-py-secret Secret Optional: false
+Environment:
+ HOST: 0.0.0.0
+ PORT: 5000
+Mounts:
+ /config from config-volume (ro)
+ /data from data-volume (rw)
+...
+```
+
+
+
+
+curl through the NodePort and PVC persistence across pod deletion
+
+```bash
+$ minikube ip
+192.168.49.2
+$ curl -sS http://192.168.49.2:30082/visits | jq .
+{
+ "visits": 0
+}
+$ curl -sS http://192.168.49.2:30082/ >/dev/null
+$ curl -sS http://192.168.49.2:30082/ >/dev/null
+$ curl -sS http://192.168.49.2:30082/visits | jq .
+{
+ "visits": 2
+}
+$ kubectl exec -n default lab12-devops-app-py-5f6df94f6d-5wxtq -- cat /data/visits
+2
+
+$ kubectl delete pod -n default lab12-devops-app-py-5f6df94f6d-5wxtq
+pod "lab12-devops-app-py-5f6df94f6d-5wxtq" deleted from default namespace
+$ kubectl wait -n default --for=condition=Ready pod -l app.kubernetes.io/instance=lab12-devops-app-py --timeout=180s
+pod/lab12-devops-app-py-5f6df94f6d-bkwfx condition met
+old_pod=lab12-devops-app-py-5f6df94f6d-5wxtq
+new_pod=lab12-devops-app-py-5f6df94f6d-bkwfx
+$ curl -sS http://192.168.49.2:30082/visits | jq .
+{
+ "visits": 2
+}
+$ kubectl exec -n default lab12-devops-app-py-5f6df94f6d-bkwfx -- cat /data/visits
+2
+```
+
+
+
+## Bonus - ConfigMap Update Behavior
+
+I deliberately mounted the ConfigMap as a directory (`/config`) instead of using `subPath`. The reason is simple: `subPath` mounts are bind mounts to a fixed inode, so they do not receive projected ConfigMap updates. For a live file update demonstration, the whole projected directory mount is the correct pattern.
+
+I tested three distinct behaviors:
+
+1. A manual `kubectl patch` against the file ConfigMap updated the mounted `/config/config.json` inside the running Pod after roughly 11 seconds.
+2. A manual patch against the env ConfigMap did not change `APP_ENV` inside the already-running process, which confirms that `envFrom` variables are fixed at container start.
+3. A chart-managed config change updated the checksum annotations and rolled the Deployment to a new Pod, which then saw the new file content and the new env vars.
+
+One practical wrinkle showed up during this: after I used `kubectl patch` on Helm-managed ConfigMaps, the next `helm upgrade` hit server-side-apply field ownership conflicts on the same keys. I repaired that by reapplying the rendered ConfigMaps with `kubectl apply --server-side --force-conflicts --field-manager=helm`, then reran a new chart-managed config change successfully.
+
+
+kubectl patch configmap lab12-devops-app-py-config and mounted-file update delay
+
+```bash
+$ jq . <<< "$PATCH"
+{
+ "data": {
+ "config.json": "{\n \"application\": {\n \"name\": \"devops-info-service\",\n \"environment\": \"manual-edit\",\n \"version\": \"1.12-dev\"\n },\n \"featureFlags\": {\n \"visitsCounter\": true,\n \"metrics\": true,\n \"configReloadDemo\": true\n },\n \"settings\": {\n \"configPath\": \"/config/config.json\",\n \"visitsFile\": \"/data/visits\",\n \"reloadStrategy\": \"checksum-rollout\"\n }\n}"
+ }
+}
+$ kubectl patch configmap -n default lab12-devops-app-py-config --type merge -p "$PATCH"
+configmap/lab12-devops-app-py-config patched
+$ wait for mounted /config/config.json to show environment=manual-edit
+delay_seconds=11
+$ kubectl exec -n default lab12-devops-app-py-5f6df94f6d-bkwfx -- cat /config/config.json | jq .application.environment
+"manual-edit"
+```
+
+
+
+
+kubectl patch configmap lab12-devops-app-py-env and proof that envFrom does not hot-reload
+
+```bash
+$ jq . <<< "$PATCH"
+{
+ "data": {
+ "APP_ENV": "manual-edit"
+ }
+}
+$ kubectl patch configmap -n default lab12-devops-app-py-env --type merge -p "$PATCH"
+configmap/lab12-devops-app-py-env patched
+$ kubectl exec -n default lab12-devops-app-py-5f6df94f6d-bkwfx -- printenv APP_ENV
+development
+```
+
+
+
+
+kubectl apply --server-side --force-conflicts --field-manager=helm to repair Helm ownership
+
+```bash
+$ helm template lab12-devops-app-py k8s/devops-app-py -n default -f k8s/devops-app-py/values-dev.yaml --set config.file.environment=chart-rollout --set config.env.data.APP_ENV=chart-rollout --show-only templates/configmap.yaml > /tmp/lab12/52-rendered-configmaps.yaml
+$ kubectl apply --server-side --force-conflicts --field-manager=helm -f /tmp/lab12/52-rendered-configmaps.yaml
+configmap/lab12-devops-app-py-config serverside-applied
+configmap/lab12-devops-app-py-env serverside-applied
+$ kubectl get configmap lab12-devops-app-py-config -n default -o json | jq -r .data["config.json"] | jq .application.environment
+"chart-rollout"
+$ kubectl get configmap lab12-devops-app-py-env -n default -o json | jq -r .data.APP_ENV
+chart-rollout
+```
+
+
+
+
+helm upgrade ... --set config.file.environment=chart-rollout-fixed --set config.env.data.APP_ENV=chart-rollout-fixed
+
+```bash
+$ helm upgrade lab12-devops-app-py k8s/devops-app-py -n default -f k8s/devops-app-py/values-dev.yaml --set config.file.environment=chart-rollout-fixed --set config.env.data.APP_ENV=chart-rollout-fixed --wait --wait-for-jobs --timeout 300s
+Release "lab12-devops-app-py" has been upgraded. Happy Helming!
+NAME: lab12-devops-app-py
+LAST DEPLOYED: Fri Apr 10 03:07:15 2026
+NAMESPACE: default
+STATUS: deployed
+REVISION: 4
+DESCRIPTION: Upgrade complete
+old_pod=lab12-devops-app-py-6b4d4cff8d-wbcnz
+new_pod=lab12-devops-app-py-7bb96994f8-n6269
+$ kubectl get deployment -n default lab12-devops-app-py -o json | jq .spec.template.metadata.annotations
+{
+ "checksum/config-env": "e34c4bf455ae82b7283e96127fcffbd6fe96332325a9e8953204033ec6ade5f5",
+ "checksum/config-file": "8d6ed3c72ff6122928a1d3e148717df696ff7cb1f6f203fcc8934903da9669a7"
+}
+$ kubectl exec -n default lab12-devops-app-py-7bb96994f8-n6269 -- cat /config/config.json | jq .application.environment
+"chart-rollout-fixed"
+$ kubectl exec -n default lab12-devops-app-py-7bb96994f8-n6269 -- printenv APP_ENV
+chart-rollout-fixed
+```
+
+
+
+
+curl end-state service check after the final rollout
+
+```bash
+$ curl -sS http://192.168.49.2:30082/health | jq .
+{
+ "status": "healthy",
+ "timestamp": "2026-04-10T00:07:47.059488+00:00",
+ "uptime_seconds": 14
+}
+$ curl -sS http://192.168.49.2:30082/ready | jq .
+{
+ "status": "ready",
+ "timestamp": "2026-04-10T00:07:47.079301+00:00",
+ "uptime_seconds": 14
+}
+$ curl -sS http://192.168.49.2:30082/visits | jq .
+{
+ "visits": 2
+}
+```
+
+
+
+## ConfigMap vs Secret
+
+- Use a `ConfigMap` for non-sensitive application configuration such as environment names, feature flags, log levels, file paths, and JSON app settings.
+- Use a `Secret` for credentials or tokens. In this repo the chart still keeps `APP_USERNAME` and `APP_PASSWORD` in a separate Secret and only references them via `envFrom`.
+- `ConfigMap` data is meant to be readable operational config. `Secret` data is still only base64-encoded unless cluster-side at-rest encryption and RBAC are configured properly.
+- Mounted ConfigMap files can update in place when the whole projected directory is mounted. Environment variables injected from either `ConfigMap` or `Secret` do not update inside an already-running container.
+
+## Task 4 - Documentation
+
+This file is the full Lab 12 report. The compatibility filename required by the lab lives at [../CONFIGMAPS.md](../CONFIGMAPS.md) and points back here so the module root does not turn into one large transcript dump.