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.