From 3c2f50a541e0e07269c066e757f23de0ad00e84a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20LEFER?= Date: Mon, 2 Jun 2025 18:21:33 +0200 Subject: [PATCH] feat: add prometheus metrics endpoint --- .coveragerc | 3 + Dockerfile | 7 +- README.md | 58 ++++- doc/assets/metrics-endpoint.png | Bin 0 -> 7631 bytes doc/prometheus.md | 32 +++ poetry.lock | 194 +++++++++++++- pyproject.toml | 8 +- scripts/entrypoint.sh | 23 ++ src/docker_volume_analyzer/docker_client.py | 95 ++++++- src/docker_volume_analyzer/volume_manager.py | 40 +-- src/docker_volume_analyzer/web.py | 47 ++++ src/docker_volume_analyzer/wsgi.py | 7 + tests/test_docker_client.py | 257 ++++++++++++++++++- tests/test_volume_manager.py | 15 +- tests/test_web.py | 44 ++++ 15 files changed, 781 insertions(+), 49 deletions(-) create mode 100644 .coveragerc create mode 100644 doc/assets/metrics-endpoint.png create mode 100644 doc/prometheus.md create mode 100755 scripts/entrypoint.sh create mode 100644 src/docker_volume_analyzer/web.py create mode 100644 src/docker_volume_analyzer/wsgi.py create mode 100644 tests/test_web.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..207ee98 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +omit = + */wsgi.py \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 84a17c8..a0fbeae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,11 @@ FROM python:3.13-slim AS runtime WORKDIR /app ENV PYTHONUNBUFFERED=1 +ENV APP_MODE=start +EXPOSE 8000 + +COPY scripts/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh ARG APP_VERSION='undefined' ENV APP_VERSION=${APP_VERSION} @@ -25,4 +30,4 @@ COPY --from=builder /build/dist/*.whl ./dist/ RUN pip install ./dist/*.whl -CMD ["start"] +ENTRYPOINT ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md index cbe2774..de2fb66 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,63 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -ti glefer/docker-v docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -ti glefer/docker-volumes-analyzer:0.1.0 ``` +## Running the Application in Different Modes + +The application supports multiple modes of operation. You can specify the mode using the `APP_MODE` environment variable. The available modes are: + +- **CLI mode** (`start`): Runs the application in command-line interface mode. +- **Web development mode** (`web`): Starts the application in web development mode. +- **Gunicorn mode** (`gunicorn`): Runs the application using Gunicorn as the WSGI server. + +### Using Docker +You can run the application in different modes by setting the `APP_MODE` environment variable when running the Docker container. + +#### CLI mode +```bash +docker run -e APP_MODE=start -v /var/run/docker.sock:/var/run/docker.sock glefer/docker-volumes-analyzer:latest +``` + +#### Web development mode +```bash +docker run -e APP_MODE=web -v /var/run/docker.sock:/var/run/docker.sock glefer/docker-volumes-analyzer:latest +``` + +#### Web production mode (gunicorn) +```bash +docker run -e APP_MODE=gunicorn -v /var/run/docker.sock:/var/run/docker.sock glefer/docker-volumes-analyzer:latest +``` + +### Using python locally +If you prefer to run the application locally without Docker, you can use the entrypoint.sh script directly. Make sure you have all dependencies installed via Poetry. + + +#### CLI mode +```bash +APP_MODE=start scripts/entrypoint.sh +``` + +#### Web development mode +```bash +APP_MODE=web scripts/entrypoint.sh +``` + +#### Web production mode (gunicorn) +```bash +APP_MODE=gunicorn scripts/entrypoint.sh +``` + +## Prometheus + +When running the application in **web** or **gunicorn** mode, it exposes a Prometheus metrics endpoint at `/metrics`. This endpoint provides detailed metrics about Docker volumes, such as: + +- Total number of Docker volumes. +- Size of individual Docker volumes (in bytes). + +![Metrics](./doc/assets/metrics-endpoint.png) + + +For more information about the metrics exposed and how to integrate them with Prometheus, refer to the [Prometheus documentation](./doc/prometheus.md). + --- ## Run tests @@ -88,7 +145,6 @@ poetry shell ``` Formatting and checks: - ```bash poetry run pre-commit run --all-files ``` diff --git a/doc/assets/metrics-endpoint.png b/doc/assets/metrics-endpoint.png new file mode 100644 index 0000000000000000000000000000000000000000..e7e16eb7e266bbc1da1afa4a14fd9ad347d5b55f GIT binary patch literal 7631 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 literal 0 HcmV?d00001 diff --git a/doc/prometheus.md b/doc/prometheus.md new file mode 100644 index 0000000..36566cc --- /dev/null +++ b/doc/prometheus.md @@ -0,0 +1,32 @@ +# Prometheus metrics endpoint +The following metrics are exposed by the Prometheus metrics endpoint: + +- **`docker_volumes_total`**: The total number of Docker volumes currently available. + - **Type**: Gauge + - **Labels**: None + - **Example**: `docker_volumes_total 42` + +- **`docker_volume_size_bytes`**: The size of each Docker volume in bytes. + - **Type**: Gauge + - **Labels**: + - `volume`: The name of the Docker volume. + - **Example**: `docker_volume_size_bytes{volume="my_volume"} 104857600` + +## Accessing the Metrics Endpoint + +The Prometheus metrics are exposed at the `/metrics` endpoint. Depending on the mode in which the application is running, you can access the endpoint as follows: + +- **Web mode**: `http://:8000/metrics` +- **Gunicorn mode**: `http://:8000/metrics` + +Replace `` with the hostname or IP address where the application is running. + +## Integrating with Prometheus + +To scrape the metrics exposed by the application, add the following job configuration to your Prometheus configuration file (`prometheus.yml`): + +```yaml +scrape_configs: + - job_name: "docker_volume_analyzer" + static_configs: + - targets: [":8000"] \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 0394bbb..007def0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -45,6 +45,18 @@ d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "blinker" +version = "1.9.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, + {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, +] + [[package]] name = "certifi" version = "2025.4.26" @@ -344,6 +356,52 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.13.0,<2.14.0" pyflakes = ">=3.3.0,<3.4.0" +[[package]] +name = "flask" +version = "3.1.1" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c"}, + {file = "flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e"}, +] + +[package.dependencies] +blinker = ">=1.9.0" +click = ">=8.1.3" +itsdangerous = ">=2.2.0" +jinja2 = ">=3.1.2" +markupsafe = ">=2.1.1" +werkzeug = ">=3.1.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "gunicorn" +version = "23.0.0" +description = "WSGI HTTP Server for UNIX" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, + {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, +] + +[package.dependencies] +packaging = "*" + +[package.extras] +eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] +tornado = ["tornado (>=0.2)"] + [[package]] name = "identify" version = "2.6.10" @@ -402,6 +460,36 @@ files = [ colors = ["colorama"] plugins = ["setuptools"] +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + [[package]] name = "linkify-it-py" version = "2.0.3" @@ -450,6 +538,77 @@ profiling = ["gprof2dot"] rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + [[package]] name = "mccabe" version = "0.7.0" @@ -594,6 +753,21 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "prometheus-client" +version = "0.22.1" +description = "Python client for the Prometheus monitoring system." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "prometheus_client-0.22.1-py3-none-any.whl", hash = "sha256:cca895342e308174341b2cbf99a56bef291fbc0ef7b9e5412a0f26d653ba7094"}, + {file = "prometheus_client-0.22.1.tar.gz", hash = "sha256:190f1331e783cf21eb60bca559354e0a4d4378facecf78f5428c39b675d20d28"}, +] + +[package.extras] +twisted = ["twisted"] + [[package]] name = "pycodestyle" version = "2.13.0" @@ -910,7 +1084,25 @@ platformdirs = ">=3.9.1,<5" docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] +[[package]] +name = "werkzeug" +version = "3.1.3" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, + {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + [metadata] lock-version = "2.1" python-versions = ">=3.13,<4.0.0" -content-hash = "3d89f0719e4475041bc76ebd76ff4bf224ea16c4f0a6720c3421a94936ae4df9" +content-hash = "b51163e0df21907a9776427ccf2f5f887d3deebe21b63bbd7983e085b10e42fc" diff --git a/pyproject.toml b/pyproject.toml index d1c19f0..a272cd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,10 @@ dependencies = [ "pre-commit (>=4.2.0,<5.0.0)", "textual (>=3.0.1,<4.0.0)", "docker (>=7.1.0,<8.0.0)", - "pytest (>=8.3.5,<9.0.0)" + "pytest (>=8.3.5,<9.0.0)", + "flask (>=3.1.1,<4.0.0)", + "prometheus-client (>=0.22.1,<0.23.0)", + "gunicorn (>=23.0.0,<24.0.0)" ] [tool.poetry] @@ -22,6 +25,7 @@ packages = [{include = "docker_volume_analyzer", from = "src"}] [tool.poetry.scripts] start = "docker_volume_analyzer.main:main" +web = "docker_volume_analyzer.web:main" [tool.poetry.group.dev.dependencies] pytest = "^8.3.5" @@ -39,4 +43,4 @@ profile = "black" line-length = 79 [tool.pytest.ini_options] -asyncio_default_fixture_loop_scope = "function" \ No newline at end of file +asyncio_default_fixture_loop_scope = "function" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100755 index 0000000..0a27f6a --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -e + +case "${APP_MODE:=start}" in + start) + echo "Starting application in CLI mode..." + start + ;; + web) + echo "Starting application in web development mode..." + web + ;; + gunicorn) + echo "Starting application with Gunicorn..." + gunicorn -w 4 -b 0.0.0.0:8000 docker_volume_analyzer.wsgi:application + ;; + *) + echo "Invalid APP_MODE: $APP_MODE" + echo "Valid options are: start, web, gunicorn" + exit 1 + ;; +esac \ No newline at end of file diff --git a/src/docker_volume_analyzer/docker_client.py b/src/docker_volume_analyzer/docker_client.py index ea45a94..6a64997 100644 --- a/src/docker_volume_analyzer/docker_client.py +++ b/src/docker_volume_analyzer/docker_client.py @@ -1,14 +1,23 @@ +import re +import time from typing import List, Union import docker +from docker.errors import NotFound +from docker.models.containers import Container from docker_volume_analyzer.errors import DockerNotAvailableError class DockerClient: + + _SIZE_RE = re.compile(r"^\d+(\.\d+)?[KMGTP]?$") + def __init__(self): try: self.client = docker.from_env() + self._volume_size_cache = {} + self._cache_timeout = 60 except docker.errors.DockerException as e: raise DockerNotAvailableError from e @@ -18,16 +27,24 @@ def list_volumes(self) -> List[docker.models.volumes.Volume]: """ return self.client.volumes.list() - def list_containers(self) -> List[docker.models.containers.Container]: + def list_containers(self) -> List[Container]: """ - Returns all running Docker container objects. + Returns all Docker container objects (running and stopped), + skipping containers that may have been removed during the process. """ - return self.client.containers.list(all=True) + containers: List[Container] = [] + for summary in self.client.api.containers(all=True): + try: + container = self.client.containers.get(summary["Id"]) + containers.append(container) + except NotFound: + continue + return containers def _run_in_container( self, command: Union[str, List[str]], - volume_name: str, + volumes_name: Union[str, List[str]], mode: str = "ro", ) -> Union[str, None]: """ @@ -42,13 +59,26 @@ def _run_in_container( Returns: str | None: Output of the command or None if failed. """ + + if isinstance(volumes_name, str): + volumes_binding = { + volumes_name: {"bind": f"/mnt/{volumes_name}", "mode": mode} + } + elif isinstance(volumes_name, list): + volumes_binding = { + name: {"bind": f"/mnt/{name}", "mode": mode} + for name in volumes_name + } + else: + raise ValueError( + "volumes_name must be a string or a list of strings" + ) + try: output = self.client.containers.run( image="alpine", command=command, - volumes={ - volume_name: {"bind": "/mnt/docker_volume", "mode": mode} - }, + volumes=volumes_binding, remove=True, stdout=True, stderr=False, @@ -58,7 +88,7 @@ def _run_in_container( print(f"[Docker Error] Command failed: {e}") return None - def get_volume_size(self, volume_name: str) -> str: + def get_volume_size(self, volume_name: Union[str, List[str]]) -> str: """ Gets human-readable size of a volume using 'du -sh'. @@ -110,9 +140,9 @@ def get_directory_informations_with_find( or None if failed. """ path = ( - f"/mnt/docker_volume/{directory}" + f"/mnt/{volume_name}/{directory}" if directory - else "/mnt/docker_volume" + else f"/mnt/{volume_name}" ) command = [ @@ -122,3 +152,48 @@ def get_directory_informations_with_find( ] output = self._run_in_container(command, volume_name) return output if output else None + + def get_volumes_size( + self, volumes_name: Union[str, List[str]], human_readable: bool = True + ): + if isinstance(volumes_name, str): + volumes_name = [volumes_name] + + current_time = time.time() + cached_results = {} + volumes_to_query = [] + + for volume in volumes_name: + cache_entry = self._volume_size_cache.get(volume) + if ( + cache_entry + and current_time - cache_entry["timestamp"] + < self._cache_timeout + ): + cached_results[volume] = cache_entry["size"] + else: + volumes_to_query.append(volume) + + if volumes_to_query: + paths = " ".join(f"/mnt/{v}" for v in volumes_to_query) + cmd = ["sh", "-c", f"du -s{'h' if human_readable else ''} {paths}"] + output = self._run_in_container(cmd, volumes_to_query) + + results = {} + for volume in volumes_to_query: + results[volume] = "0" # Default size is "0" + if output: + for line, volume in zip(output.splitlines(), volumes_to_query): + parts = line.split(maxsplit=1) + if len(parts) >= 1 and self._SIZE_RE.match(parts[0]): + results[volume] = parts[0] + + for volume, size in results.items(): + self._volume_size_cache[volume] = { + "size": size, + "timestamp": current_time, + } + + cached_results.update(results) + + return cached_results diff --git a/src/docker_volume_analyzer/volume_manager.py b/src/docker_volume_analyzer/volume_manager.py index b1c5f3d..1a2b60e 100644 --- a/src/docker_volume_analyzer/volume_manager.py +++ b/src/docker_volume_analyzer/volume_manager.py @@ -1,12 +1,14 @@ +from typing import List + from docker_volume_analyzer.docker_client import DockerClient from docker_volume_analyzer.filesystem import parse_find_output class VolumeManager: - def __init__(self): - self.client = DockerClient() + def __init__(self, docker_client: DockerClient | None = None): + self.client = docker_client or DockerClient() - def get_volumes(self) -> dict: + def get_volumes(self, human_readable: bool = True) -> dict: """ Return all Docker volumes name and mountpoint @@ -15,17 +17,28 @@ def get_volumes(self) -> dict: and mount points as values. """ + volumes = self.client.list_volumes() + + # Fetch containers associated with volumes containers_by_volumes = self.get_containers_by_volume() + # Fetch sizes for all volumes in a single call + volume_sizes = self.get_volumes_size( + [volume.name for volume in volumes], human_readable + ) + + # Build the result dictionary return { volume.name: { "name": volume.name, "mountpoint": volume.attrs.get("Mountpoint", ""), - "size": self.get_volume_size(volume.name), + "size": volume_sizes.get( + volume.name, "0" + ), # Use pre-fetched sizes "created_at": volume.attrs.get("CreatedAt", ""), "containers": containers_by_volumes.get(volume.name, []), } - for volume in self.client.list_volumes() + for volume in volumes } def get_containers_by_volume(self) -> dict: @@ -54,18 +67,6 @@ def get_containers_by_volume(self) -> dict: ) return containers_by_volumes - def get_volume_size(self, volume_name: str) -> int: - """ - Get the size of a Docker volume. - - Args: - volume_name (str): Name of the Docker volume. - - Returns: - int: Size of the volume in bytes. - """ - return self.client.get_volume_size(volume_name) - def delete_volume(self, volume_name: str) -> bool: """ Delete a Docker volume by its name. @@ -99,3 +100,8 @@ def get_volume_tree(self, volume_name: str) -> dict: return {} return parse_find_output(find_result).compute_directory_sizes() + + def get_volumes_size( + self, volume_names=List[str], human_readable: bool = True + ) -> dict: + return self.client.get_volumes_size(volume_names, human_readable) diff --git a/src/docker_volume_analyzer/web.py b/src/docker_volume_analyzer/web.py new file mode 100644 index 0000000..48d3874 --- /dev/null +++ b/src/docker_volume_analyzer/web.py @@ -0,0 +1,47 @@ +from flask import Flask, Response +from prometheus_client import CollectorRegistry, Gauge, generate_latest + +from docker_volume_analyzer.docker_client import DockerClient +from docker_volume_analyzer.volume_manager import VolumeManager + +app = Flask(__name__) + +registry = CollectorRegistry() + +docker_volumes_total = Gauge( + "docker_volumes_total", "Total number of Docker volumes", registry=registry +) +docker_volume_size_bytes = Gauge( + "docker_volume_size_bytes", + "Size of individual Docker volumes in bytes", + ["name"], + registry=registry, +) + +docker_client = DockerClient() + + +@app.route("/") +def index(): + return ( + "

Docker Volume Analyzer Metrics Endpoint.
" + "Visit /metrics for Prometheus metrics.

" + ) + + +@app.route("/metrics") +def metrics(): + volume_manager = VolumeManager(docker_client=docker_client) + volumes = volume_manager.get_volumes(human_readable=False) + docker_volumes_total.set(len(volumes)) + for volume_name, volume_info in volumes.items(): + docker_volume_size_bytes.labels(name=volume_name).set( + volume_info["size"] + ) + + # Return metrics in Prometheus format + return Response(generate_latest(registry), mimetype="text/plain") + + +def main(): + app.run(host="0.0.0.0", port=8000) # pragma: no cover diff --git a/src/docker_volume_analyzer/wsgi.py b/src/docker_volume_analyzer/wsgi.py new file mode 100644 index 0000000..1955506 --- /dev/null +++ b/src/docker_volume_analyzer/wsgi.py @@ -0,0 +1,7 @@ +# pragma: no cover +from docker_volume_analyzer.web import app + +application = app + +if __name__ == "__main__": + app.run() diff --git a/tests/test_docker_client.py b/tests/test_docker_client.py index de7432a..507dfcf 100644 --- a/tests/test_docker_client.py +++ b/tests/test_docker_client.py @@ -1,3 +1,4 @@ +import time from unittest.mock import MagicMock, patch import docker @@ -30,22 +31,60 @@ def test_list_containers(): Test the list_containers method of DockerClient. """ mock_client = MagicMock() - mock_client.containers.list.return_value = [ - MagicMock(name="Container", id="container1"), - MagicMock(name="Container", id="container2"), + mock_client.api.containers.return_value = [ + {"Id": "container1"}, + {"Id": "container2"}, ] + mock_client.containers.get.side_effect = lambda container_id: MagicMock( + name="Container", id=container_id + ) docker_client = DockerClient() docker_client.client = mock_client containers = docker_client.list_containers() - + assert len(containers) == 2 + assert containers[0].id == "container1" + assert containers[1].id == "container2" assert len(containers) == 2 -def test_run_in_container(): +@pytest.mark.parametrize( + "volumes_input, mode, expected_volumes", + [ + ( + "volume_name", + "ro", + {"volume_name": {"bind": "/mnt/volume_name", "mode": "ro"}}, + ), + ( + ["volume1", "volume2"], + "ro", + { + "volume1": {"bind": "/mnt/volume1", "mode": "ro"}, + "volume2": {"bind": "/mnt/volume2", "mode": "ro"}, + }, + ), + ( + "volume_name", + "rw", + {"volume_name": {"bind": "/mnt/volume_name", "mode": "rw"}}, + ), + ( + ["volume1", "volume2"], + "rw", + { + "volume1": {"bind": "/mnt/volume1", "mode": "rw"}, + "volume2": {"bind": "/mnt/volume2", "mode": "rw"}, + }, + ), + ([], "ro", {}), + ], +) +def test_run_in_container(volumes_input, mode, expected_volumes): """ - Test the _run_in_container method of DockerClient. + Test the _run_in_container method of DockerClient for single + multiple, and edge cases. """ mock_client = MagicMock() mock_client.containers.run.return_value = b"output" @@ -53,12 +92,12 @@ def test_run_in_container(): docker_client = DockerClient() docker_client.client = mock_client - output = docker_client._run_in_container("ls", "volume_name") + output = docker_client._run_in_container("ls", volumes_input, mode=mode) docker_client.client.containers.run.assert_called_with( image="alpine", command="ls", - volumes={"volume_name": {"bind": "/mnt/docker_volume", "mode": "ro"}}, + volumes=expected_volumes, remove=True, stdout=True, stderr=False, @@ -194,10 +233,10 @@ def test_get_directory_informations_with_find(): command=[ "sh", "-c", - "find /mnt/docker_volume/test_dir -exec stat " + "find /mnt/test_volume/test_dir -exec stat " "-c '%F|%n|%s|%A|%U|%G|%Y' {} \\;", ], - volumes={"test_volume": {"bind": "/mnt/docker_volume", "mode": "ro"}}, + volumes={"test_volume": {"bind": "/mnt/test_volume", "mode": "ro"}}, remove=True, stdout=True, stderr=False, @@ -234,10 +273,10 @@ def test_get_directory_informations_with_find_no_directory(): command=[ "sh", "-c", - "find /mnt/docker_volume -exec stat " + "find /mnt/test_volume -exec stat " "-c '%F|%n|%s|%A|%U|%G|%Y' {} \\;", ], - volumes={"test_volume": {"bind": "/mnt/docker_volume", "mode": "ro"}}, + volumes={"test_volume": {"bind": "/mnt/test_volume", "mode": "ro"}}, remove=True, stdout=True, stderr=False, @@ -277,13 +316,203 @@ def test_get_directory_informations_with_find_error(): command=[ "sh", "-c", - "find /mnt/docker_volume/test_dir -exec stat " + "find /mnt/test_volume/test_dir -exec stat " "-c '%F|%n|%s|%A|%U|%G|%Y' {} \\;", ], - volumes={"test_volume": {"bind": "/mnt/docker_volume", "mode": "ro"}}, + volumes={"test_volume": {"bind": "/mnt/test_volume", "mode": "ro"}}, remove=True, stdout=True, stderr=False, ) assert output is None + + +def test_list_containers_not_found(): + """ + Test the list_containers method of DockerClient + when a container is not found. + """ + mock_client = MagicMock() + mock_client.api.containers.return_value = [ + {"Id": "container1"}, + {"Id": "container2"}, + ] + mock_client.containers.get.side_effect = [ + MagicMock(name="Container", id="container1"), + docker.errors.NotFound("Container not found"), + ] + + docker_client = DockerClient() + docker_client.client = mock_client + + containers = docker_client.list_containers() + + assert len(containers) == 1 + assert containers[0].id == "container1" + + +@pytest.mark.parametrize( + "volumes_input, human_readable, expected_output", + [ + ("volume1", True, {"volume1": "10M"}), + (["volume1", "volume2"], True, {"volume1": "10M", "volume2": "20M"}), + ( + ["volume1", "volume2"], + False, + {"volume1": "10240", "volume2": "20480"}, + ), + ], +) +def test_get_volumes_size(volumes_input, human_readable, expected_output): + """ + Test the get_volumes_size method of DockerClient + for single and multiple volumes. + """ + mock_client = MagicMock() + mock_client.containers.run.return_value = ( + b"10M\tdirectory-path\n20M\tdirectory-path" + if human_readable + else b"10240\tdirectory-path\n20480\tdirectory-path" + ) + + docker_client = DockerClient() + docker_client.client = mock_client + + result = docker_client.get_volumes_size(volumes_input, human_readable) + + docker_client.client.containers.run.assert_called_with( + image="alpine", + command=[ + "sh", + "-c", + f"du -s{'h' if human_readable else ''} " + + " ".join( + [ + f"/mnt/{vol}" + for vol in ( + volumes_input + if isinstance(volumes_input, list) + else [volumes_input] + ) + ] + ), + ], + volumes={ + vol: {"bind": f"/mnt/{vol}", "mode": "ro"} + for vol in ( + volumes_input + if isinstance(volumes_input, list) + else [volumes_input] + ) + }, + remove=True, + stdout=True, + stderr=False, + ) + + assert result == expected_output + + +def test_get_volumes_size_no_output(): + """ + Test the get_volumes_size method of DockerClient + when no output is returned. + """ + mock_client = MagicMock() + mock_client.containers.run.return_value = b"" + + docker_client = DockerClient() + docker_client.client = mock_client + + result = docker_client.get_volumes_size(["volume1", "volume2"]) + + assert result == {"volume1": "0", "volume2": "0"} + + +def test_get_volumes_size_index_error(): + """ + Test the get_volumes_size method of DockerClient when IndexError occurs. + """ + mock_client = MagicMock() + mock_client.containers.run.return_value = b"invalid_output" + + docker_client = DockerClient() + docker_client.client = mock_client + + result = docker_client.get_volumes_size(["volume1", "volume2"]) + + assert result == {"volume1": "0", "volume2": "0"} + + +def test_run_in_container_invalid_volumes_name(): + """ + Test the _run_in_container method of DockerClient when an invalid + volumes_name is provided. + """ + docker_client = DockerClient() + + with pytest.raises( + ValueError, match="volumes_name must be a string or a list of strings" + ): + docker_client._run_in_container("ls", 123) + + +@pytest.mark.parametrize( + "cached_volumes, requested_volumes, expected_result", + [ + # Case where all requested volumes are cached + ( + { + "volume1": {"size": "10M", "timestamp": time.time()}, + "volume2": {"size": "20M", "timestamp": time.time()}, + }, + ["volume1", "volume2"], + {"volume1": "10M", "volume2": "20M"}, + ), + # Case where some volumes are cached, and one needs to be queried + ( + { + "volume1": {"size": "10M", "timestamp": time.time()}, + "volume2": {"size": "20M", "timestamp": time.time() - 30}, + }, + ["volume1", "volume2", "volume3"], + {"volume1": "10M", "volume2": "20M", "volume3": "30M"}, + ), + ], +) +def test_get_volumes_size_with_cache( + cached_volumes, requested_volumes, expected_result +): + """ + Test the get_volumes_size method of DockerClient + when cache is used for some or all volumes. + """ + mock_client = MagicMock() + docker_client = DockerClient() + docker_client.client = mock_client + + docker_client._volume_size_cache = cached_volumes + docker_client._cache_timeout = 60 + + mock_client.containers.run.return_value = b"30M\tdirectory-path" + + result = docker_client.get_volumes_size(requested_volumes) + + assert result == expected_result + + if "volume3" in requested_volumes: + mock_client.containers.run.assert_called_once_with( + image="alpine", + command=[ + "sh", + "-c", + "du -sh /mnt/volume3", + ], + volumes={"volume3": {"bind": "/mnt/volume3", "mode": "ro"}}, + remove=True, + stdout=True, + stderr=False, + ) + else: + mock_client.containers.run.assert_not_called() diff --git a/tests/test_volume_manager.py b/tests/test_volume_manager.py index 5109dd5..fbef661 100644 --- a/tests/test_volume_manager.py +++ b/tests/test_volume_manager.py @@ -88,16 +88,25 @@ def test_get_volumes() -> None: num_volumes, max_containers_per_volume=5 ) + # Extraire les tailles déjà connues depuis expected + expected_sizes = { + volume_name: data["size"] for volume_name, data in expected.items() + } + + # Configurer le client mocké mock_client = MagicMock() mock_client.list_volumes.return_value = volumes mock_client.list_containers.return_value = containers - mock_client.get_volume_size.side_effect = ( - lambda name: int(name.replace("volume", "")) * 10 + 10 - ) + mock_client.get_volumes_size.side_effect = lambda *args: { + name: expected_sizes[name] for name in args[0] + } volume_manager = VolumeManager() volume_manager.client = mock_client + result = volume_manager.get_volumes() + + assert result == expected assert volume_manager.get_volumes() == expected diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..3fe07fb --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,44 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from docker_volume_analyzer.web import app + + +@pytest.fixture +def client(): + """Fixture to create a test client for the Flask app.""" + with app.test_client() as client: + yield client + + +@patch("docker_volume_analyzer.web.VolumeManager") +def test_metrics_endpoint(mock_volume_manager, client): + """ + Test the /metrics endpoint to ensure it returns Prometheus metrics. + """ + mock_volume_manager_instance = MagicMock() + mock_volume_manager.return_value = mock_volume_manager_instance + mock_volume_manager_instance.get_volumes.return_value = { + "volume1": {"size": 1024}, + "volume2": {"size": 2048}, + } + + response = client.get("/metrics") + + assert response.status_code == 200 + + assert b"docker_volumes_total 2" in response.data + assert b'docker_volume_size_bytes{name="volume1"} 1024.0' in response.data + assert b'docker_volume_size_bytes{name="volume2"} 2048.0' in response.data + + +def test_index_endpoint(client): + """ + Test the / endpoint to ensure it returns the correct HTML response. + """ + response = client.get("/") + + assert response.status_code == 200 + + assert b"Docker Volume Analyzer Metrics Endpoint" in response.data