From 3cd4d0bcfa2a7b26c5c6e1ee36200de3303e4fec Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Tue, 31 Mar 2026 09:27:09 +0300 Subject: [PATCH 1/6] Move DB credentials to secrets manager --- .env.example | 5 +++++ CHANGELOG.md | 15 +++++++++++++++ docker-compose.yml | 14 +++++++++----- k8s/configmap.yaml | 4 +++- k8s/deployment.yaml | 2 ++ k8s/secret.yaml | 9 +++++++++ minikube/01-configmap.yaml | 4 +++- minikube/01a-secret.yaml | 9 +++++++++ minikube/02-postgres.yaml | 10 ++++++++-- minikube/04-deployment.yaml | 2 ++ src/config.py | 20 +++++++++++++++++++- tests/presentation/test_api.py | 13 +++++++++++-- tests/presentation/test_correlation.py | 6 +++++- 13 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 .env.example create mode 100644 k8s/secret.yaml create mode 100644 minikube/01a-secret.yaml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..302b683 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy to .env and fill in real values. +# docker compose reads this file automatically. +POSTGRES_USER=postgres +POSTGRES_PASSWORD=changeme +POSTGRES_DB=images diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c3793f..d19f9cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- Moved database credentials out of source code and configuration defaults. + `IMG_DB_USER` and `IMG_DB_PASSWORD` are now required environment variables + with no hardcoded fallback. The `IMG_DATABASE_URL` setting is replaced by + individual `IMG_DB_USER`, `IMG_DB_PASSWORD`, `IMG_DB_HOST`, `IMG_DB_PORT`, + and `IMG_DB_NAME` variables. Kubernetes manifests use `Secret` resources + instead of storing credentials in ConfigMaps. Docker Compose reads + credentials from the host environment (see `.env.example`). + +### Changed + +- `Settings.database_url` is now a computed property assembled from individual + DB credential fields, with password URL-encoding via `urllib.parse.quote_plus`. + ## [1.3.0] - 2026-03-31 ### Added diff --git a/docker-compose.yml b/docker-compose.yml index 17d1a24..3b1d978 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,11 @@ services: ports: - "8000:8000" environment: - IMG_DATABASE_URL: "postgresql+asyncpg://postgres:postgres@db:5432/images" + IMG_DB_USER: "${POSTGRES_USER:-postgres}" + IMG_DB_PASSWORD: "${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}" + IMG_DB_HOST: "db" + IMG_DB_PORT: "5432" + IMG_DB_NAME: "${POSTGRES_DB:-images}" IMG_STORAGE_BASE_DIR: "/data/images" IMG_PROCESSING_MAX_WORKERS: "4" IMG_DEBUG: "true" @@ -17,15 +21,15 @@ services: db: image: postgres:16-alpine environment: - POSTGRES_DB: images - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + POSTGRES_DB: "${POSTGRES_DB:-images}" + POSTGRES_USER: "${POSTGRES_USER:-postgres}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}" ports: - "5432:5432" volumes: - pg_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] interval: 5s timeout: 5s retries: 5 diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml index 7587472..6a67fef 100644 --- a/k8s/configmap.yaml +++ b/k8s/configmap.yaml @@ -4,7 +4,9 @@ metadata: name: image-service-config namespace: cv-platform data: - IMG_DATABASE_URL: "postgresql+asyncpg://postgres:postgres@postgres-svc:5432/images" + IMG_DB_HOST: "postgres-svc" + IMG_DB_PORT: "5432" + IMG_DB_NAME: "images" IMG_STORAGE_BASE_DIR: "/data/images" IMG_PROCESSING_MAX_WORKERS: "4" IMG_DB_POOL_SIZE: "20" diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index edba365..46ce4d5 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -24,6 +24,8 @@ spec: envFrom: - configMapRef: name: image-service-config + - secretRef: + name: image-service-db-credentials resources: requests: cpu: "250m" diff --git a/k8s/secret.yaml b/k8s/secret.yaml new file mode 100644 index 0000000..d9132d5 --- /dev/null +++ b/k8s/secret.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: image-service-db-credentials + namespace: cv-platform +type: Opaque +stringData: + IMG_DB_USER: "postgres" # replace or inject via sealed-secrets / external-secrets + IMG_DB_PASSWORD: "changeme" # replace or inject via sealed-secrets / external-secrets diff --git a/minikube/01-configmap.yaml b/minikube/01-configmap.yaml index a519cf4..289a6a8 100644 --- a/minikube/01-configmap.yaml +++ b/minikube/01-configmap.yaml @@ -4,7 +4,9 @@ metadata: name: image-service-config namespace: cv-platform data: - IMG_DATABASE_URL: "postgresql+asyncpg://postgres:postgres@postgres-svc:5432/images" + IMG_DB_HOST: "postgres-svc" + IMG_DB_PORT: "5432" + IMG_DB_NAME: "images" IMG_STORAGE_BASE_DIR: "/data/images" IMG_PROCESSING_MAX_WORKERS: "2" IMG_DB_POOL_SIZE: "5" diff --git a/minikube/01a-secret.yaml b/minikube/01a-secret.yaml new file mode 100644 index 0000000..d9132d5 --- /dev/null +++ b/minikube/01a-secret.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: image-service-db-credentials + namespace: cv-platform +type: Opaque +stringData: + IMG_DB_USER: "postgres" # replace or inject via sealed-secrets / external-secrets + IMG_DB_PASSWORD: "changeme" # replace or inject via sealed-secrets / external-secrets diff --git a/minikube/02-postgres.yaml b/minikube/02-postgres.yaml index 5a18b53..31b62ad 100644 --- a/minikube/02-postgres.yaml +++ b/minikube/02-postgres.yaml @@ -38,9 +38,15 @@ spec: - name: POSTGRES_DB value: images - name: POSTGRES_USER - value: postgres + valueFrom: + secretKeyRef: + name: image-service-db-credentials + key: IMG_DB_USER - name: POSTGRES_PASSWORD - value: postgres + valueFrom: + secretKeyRef: + name: image-service-db-credentials + key: IMG_DB_PASSWORD resources: requests: cpu: "100m" diff --git a/minikube/04-deployment.yaml b/minikube/04-deployment.yaml index b69f556..c7e8806 100644 --- a/minikube/04-deployment.yaml +++ b/minikube/04-deployment.yaml @@ -25,6 +25,8 @@ spec: envFrom: - configMapRef: name: image-service-config + - secretRef: + name: image-service-db-credentials resources: requests: cpu: "100m" diff --git a/src/config.py b/src/config.py index 12eecdf..661767f 100644 --- a/src/config.py +++ b/src/config.py @@ -2,6 +2,9 @@ from __future__ import annotations +from urllib.parse import quote_plus + +from pydantic import computed_field from pydantic_settings import BaseSettings @@ -11,10 +14,25 @@ class Settings(BaseSettings): debug: bool = False # ── Database ───────────────────────────────────────────────────────── - database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/images" + # Credentials MUST be supplied via environment variables or a secrets + # manager; no defaults are provided to prevent accidental leakage. + db_user: str # required — no default + db_password: str # required — no default + db_host: str = "localhost" + db_port: int = 5432 + db_name: str = "images" db_pool_size: int = 10 db_max_overflow: int = 20 + @computed_field # type: ignore[prop-decorator] + @property + def database_url(self) -> str: + password = quote_plus(self.db_password) + return ( + f"postgresql+asyncpg://{self.db_user}:{password}" + f"@{self.db_host}:{self.db_port}/{self.db_name}" + ) + # ── Storage ────────────────────────────────────────────────────────── storage_base_dir: str = "/data/images" diff --git a/tests/presentation/test_api.py b/tests/presentation/test_api.py index 701d16e..dc25131 100644 --- a/tests/presentation/test_api.py +++ b/tests/presentation/test_api.py @@ -96,7 +96,11 @@ async def _noop_lifespan(app): app.dependency_overrides[get_process_use_case] = lambda: mock_process # Health endpoint: provide real tmp_path for storage and mock the DB check - test_settings = Settings(storage_base_dir=str(tmp_path)) + test_settings = Settings( + storage_base_dir=str(tmp_path), + db_user="test", + db_password="test", + ) app.dependency_overrides[get_settings] = lambda: test_settings async def _ok_db_check(): @@ -206,7 +210,12 @@ async def _noop_lifespan(app): app.dependency_overrides[get_list_use_case] = lambda: mock_list app.dependency_overrides[get_process_use_case] = lambda: mock_process - test_settings = Settings(storage_base_dir=str(tmp_path), api_key="test-secret-key") + test_settings = Settings( + storage_base_dir=str(tmp_path), + api_key="test-secret-key", + db_user="test", + db_password="test", + ) app.dependency_overrides[get_settings] = lambda: test_settings async def _ok_db_check(): diff --git a/tests/presentation/test_correlation.py b/tests/presentation/test_correlation.py index bff661e..c68e91a 100644 --- a/tests/presentation/test_correlation.py +++ b/tests/presentation/test_correlation.py @@ -141,7 +141,11 @@ async def _noop_lifespan(app): ) app.dependency_overrides[get_list_use_case] = lambda: mock_list - test_settings = Settings(storage_base_dir=str(tmp_path)) + test_settings = Settings( + storage_base_dir=str(tmp_path), + db_user="test", + db_password="test", + ) app.dependency_overrides[get_settings] = lambda: test_settings async def _ok_db(): From 865fe667f7d894a231d46de318a11c57dbdb88e3 Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Tue, 31 Mar 2026 09:37:09 +0300 Subject: [PATCH 2/6] Fix minikube setup --- Dockerfile | 2 ++ minikube/setup.sh | 1 + 2 files changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index f72982c..abfe990 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,9 @@ RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY --from=builder /install /usr/local +COPY pyproject.toml . COPY src/ src/ +RUN pip install --no-cache-dir --no-deps . RUN mkdir -p /data/images && chown -R appuser:appuser /data diff --git a/minikube/setup.sh b/minikube/setup.sh index ca9b2f2..b0482db 100755 --- a/minikube/setup.sh +++ b/minikube/setup.sh @@ -57,6 +57,7 @@ fi info "Applying Kubernetes manifests..." kubectl apply -f "$SCRIPT_DIR/00-namespace.yaml" kubectl apply -f "$SCRIPT_DIR/01-configmap.yaml" +kubectl apply -f "$SCRIPT_DIR/01a-secret.yaml" kubectl apply -f "$SCRIPT_DIR/02-postgres.yaml" kubectl apply -f "$SCRIPT_DIR/03-pvc.yaml" kubectl apply -f "$SCRIPT_DIR/04-deployment.yaml" From 840de6bb82d8e493cb84c586506adb4debe2a28c Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Tue, 31 Mar 2026 09:43:52 +0300 Subject: [PATCH 3/6] Add Security Context --- CHANGELOG.md | 4 ++++ k8s/deployment.yaml | 11 +++++++++++ minikube/02-postgres.yaml | 15 +++++++++++++++ minikube/04-deployment.yaml | 11 +++++++++++ 4 files changed, 41 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d19f9cb..d124e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and `IMG_DB_NAME` variables. Kubernetes manifests use `Secret` resources instead of storing credentials in ConfigMaps. Docker Compose reads credentials from the host environment (see `.env.example`). +- Added Kubernetes `SecurityContext` to all deployments: `runAsNonRoot: true`, + `allowPrivilegeEscalation: false`, and `readOnlyRootFilesystem: true`. + Writable paths (`/data/images`, `/tmp`, PostgreSQL's `/var/run/postgresql`) + use `emptyDir` or PVC mounts. ### Changed diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 46ce4d5..6de2eea 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -15,9 +15,16 @@ spec: labels: app: image-service spec: + securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 containers: - name: image-service image: registry.example.com/cv-team/image-service:latest + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true ports: - containerPort: 8000 protocol: TCP @@ -48,7 +55,11 @@ spec: volumeMounts: - name: image-data mountPath: /data/images + - name: tmp + mountPath: /tmp volumes: - name: image-data persistentVolumeClaim: claimName: image-data-pvc + - name: tmp + emptyDir: {} diff --git a/minikube/02-postgres.yaml b/minikube/02-postgres.yaml index 31b62ad..fbe0087 100644 --- a/minikube/02-postgres.yaml +++ b/minikube/02-postgres.yaml @@ -29,9 +29,16 @@ spec: labels: app: postgres spec: + securityContext: + runAsNonRoot: true + runAsUser: 70 # postgres user in Alpine + fsGroup: 70 containers: - name: postgres image: postgres:16-alpine + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true ports: - containerPort: 5432 env: @@ -62,10 +69,18 @@ spec: volumeMounts: - name: pg-data mountPath: /var/lib/postgresql/data + - name: pg-run + mountPath: /var/run/postgresql + - name: tmp + mountPath: /tmp volumes: - name: pg-data persistentVolumeClaim: claimName: postgres-pvc + - name: pg-run + emptyDir: {} + - name: tmp + emptyDir: {} --- apiVersion: v1 kind: Service diff --git a/minikube/04-deployment.yaml b/minikube/04-deployment.yaml index c7e8806..8cfccb4 100644 --- a/minikube/04-deployment.yaml +++ b/minikube/04-deployment.yaml @@ -15,10 +15,17 @@ spec: labels: app: image-service spec: + securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 containers: - name: image-service image: image-service:latest imagePullPolicy: Never # use image built inside minikube's docker + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true ports: - containerPort: 8000 protocol: TCP @@ -49,7 +56,11 @@ spec: volumeMounts: - name: image-data mountPath: /data/images + - name: tmp + mountPath: /tmp volumes: - name: image-data persistentVolumeClaim: claimName: image-data-pvc + - name: tmp + emptyDir: {} From bf2e43761feaabc38ad44d63261e880a37dc8159 Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Tue, 31 Mar 2026 09:46:08 +0300 Subject: [PATCH 4/6] Update documentation --- AGENTS.md | 6 +++++- PROJECT_DESCRIPTION.md | 11 +++++++++-- README.md | 9 +++++++-- REQUIREMENTS.md | 2 +- minikube/README.md | 3 ++- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a6a47c2..5e49e47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,7 +120,11 @@ All settings use the `IMG_` environment variable prefix via pydantic-settings. K | Variable | Default | Purpose | |---|---|---| -| `IMG_DATABASE_URL` | `postgresql+asyncpg://postgres:postgres@localhost:5432/images` | DB connection | +| `IMG_DB_USER` | *(required)* | Database username | +| `IMG_DB_PASSWORD` | *(required)* | Database password | +| `IMG_DB_HOST` | `localhost` | Database hostname | +| `IMG_DB_PORT` | `5432` | Database port | +| `IMG_DB_NAME` | `images` | Database name | | `IMG_STORAGE_BASE_DIR` | `/data/images` | File storage path | | `IMG_PROCESSING_MAX_WORKERS` | `4` | CPU worker pool size | | `IMG_THUMBNAIL_MAX_SIZE` | `256` | Max thumbnail dimension (px) | diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index 8ba694a..165899e 100644 --- a/PROJECT_DESCRIPTION.md +++ b/PROJECT_DESCRIPTION.md @@ -110,9 +110,12 @@ The service is designed to run in a Kubernetes environment with production-grade | `deployment.yaml` | 2 replicas, resource requests/limits, liveness and readiness probes | | `service.yaml` | ClusterIP service exposing port 80 → 8000 | | `hpa.yaml` | Horizontal Pod Autoscaler: 2–10 replicas based on CPU (70%) and memory (80%) | -| `configmap.yaml` | Environment configuration (database URL, pool sizes, storage path) | +| `configmap.yaml` | Environment configuration (database host, pool sizes, storage path) | +| `secret.yaml` | Database credentials (`IMG_DB_USER`, `IMG_DB_PASSWORD`) via Kubernetes Secret | | `pvc.yaml` | 50Gi PersistentVolumeClaim with ReadWriteMany access | +All pods run with hardened **SecurityContext**: `runAsNonRoot: true`, `allowPrivilegeEscalation: false`, and `readOnlyRootFilesystem: true` (writable paths use `emptyDir` or PVC mounts). + A complete **Minikube demo** is included (`minikube/`) with automated setup, teardown, and demo scripts that deploy the full stack locally and exercise all API endpoints. --- @@ -123,7 +126,11 @@ All settings are provided via environment variables (prefix `IMG_`) using **pyda | Variable | Default | Description | |----------|---------|-------------| -| `IMG_DATABASE_URL` | `postgresql+asyncpg://...` | Async database connection string | +| `IMG_DB_USER` | *(required)* | Database username (no default — must be set) | +| `IMG_DB_PASSWORD` | *(required)* | Database password (no default — must be set) | +| `IMG_DB_HOST` | `localhost` | Database hostname | +| `IMG_DB_PORT` | `5432` | Database port | +| `IMG_DB_NAME` | `images` | Database name | | `IMG_DB_POOL_SIZE` | `10` | Connection pool size | | `IMG_DB_MAX_OVERFLOW` | `20` | Maximum overflow connections | | `IMG_STORAGE_BASE_DIR` | `/data/images` | File storage path | diff --git a/README.md b/README.md index 7ff4cac..c569308 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ pip install -e ".[dev]" pytest tests/ -v # Start the server (requires PostgreSQL) -export IMG_DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/images" +export IMG_DB_USER="postgres" +export IMG_DB_PASSWORD="postgres" uvicorn src.main:app --reload ``` @@ -79,7 +80,11 @@ All settings via environment variables (prefix `IMG_`), validated by [pydantic-s | Variable | Default | Description | |----------|---------|-------------| -| `IMG_DATABASE_URL` | `postgresql+asyncpg://...` | Async database connection string | +| `IMG_DB_USER` | *(required)* | Database username (no default — must be set) | +| `IMG_DB_PASSWORD` | *(required)* | Database password (no default — must be set) | +| `IMG_DB_HOST` | `localhost` | Database hostname | +| `IMG_DB_PORT` | `5432` | Database port | +| `IMG_DB_NAME` | `images` | Database name | | `IMG_DB_POOL_SIZE` | `10` | SQLAlchemy connection pool size | | `IMG_DB_MAX_OVERFLOW` | `20` | Max overflow connections | | `IMG_STORAGE_BASE_DIR` | `/data/images` | Image file storage path | diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 20efcbc..9d049d9 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -158,7 +158,7 @@ | NFR-5.1 | Content-type validation | `ALLOWED_CONTENT_TYPES` check before processing | | NFR-5.2 | Upload size bound | `MAX_UPLOAD_SIZE = 50 MB` | | NFR-5.3 | Non-root Docker | `USER appuser` in Dockerfile | -| NFR-5.4 | Injectable credentials | `IMG_DATABASE_URL` env var | +| NFR-5.4 | Injectable credentials | `IMG_DB_USER`/`IMG_DB_PASSWORD` env vars (required, no defaults) | | NFR-5.5 | API boundary validation | `ImageUploadParams`, `BatchProcessRequest` — Pydantic constraints | | NFR-6.1 | Pure domain unit tests | `tests/domain/` — no mocks, no I/O | | NFR-6.2 | Mocked use case tests | `tests/application/` — `AsyncMock` for all ports | diff --git a/minikube/README.md b/minikube/README.md index 996e15d..5e8dd83 100644 --- a/minikube/README.md +++ b/minikube/README.md @@ -69,7 +69,8 @@ cv-platform namespace │ ├── image-service (NodePort :80 → :8000, nodePort 30080) │ ├── image-data-pvc (2Gi) │ └── image-service-hpa (1–4 replicas, 70% CPU target) -└── image-service-config (ConfigMap) +├── image-service-config (ConfigMap) +└── image-service-db-credentials (Secret) ``` ## Manual Access From 31535e1ad65f7a6a15ae0e85e0e86c957d81600a Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Tue, 31 Mar 2026 09:49:28 +0300 Subject: [PATCH 5/6] Update version --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- src/presentation/api/dependencies.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d124e59..9ba667a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0] - 2026-03-31 + ### Security - Moved database credentials out of source code and configuration defaults. diff --git a/pyproject.toml b/pyproject.toml index f64bacb..fd4db76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "image-processing-service" -version = "1.3.0" +version = "2.0.0" description = "High-performance image processing microservice with Clean Architecture" requires-python = ">=3.11" dependencies = [ diff --git a/src/presentation/api/dependencies.py b/src/presentation/api/dependencies.py index fd94e74..4b4f0a9 100644 --- a/src/presentation/api/dependencies.py +++ b/src/presentation/api/dependencies.py @@ -30,7 +30,7 @@ @lru_cache def get_settings() -> Settings: - return Settings() + return Settings() # type: ignore[call-arg] # pydantic-settings reads from env _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) From 1853b0980f8f0adfb1d42ec34b4323fdb6436b78 Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Tue, 31 Mar 2026 09:56:39 +0300 Subject: [PATCH 6/6] Fix CI build --- src/presentation/api/dependencies.py | 47 +++++++++++++++++------- src/presentation/api/routes/images.py | 12 +++--- src/presentation/api/routes/retention.py | 2 +- 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/src/presentation/api/dependencies.py b/src/presentation/api/dependencies.py index 4b4f0a9..4e0f20a 100644 --- a/src/presentation/api/dependencies.py +++ b/src/presentation/api/dependencies.py @@ -10,7 +10,7 @@ from functools import lru_cache from typing import Annotated -from fastapi import Depends, HTTPException, Security, status +from fastapi import Depends, HTTPException, Request, Security, status from fastapi.security import APIKeyHeader from src.application.use_cases.apply_retention import ApplyRetentionUseCase @@ -88,22 +88,43 @@ def _processor() -> PillowImageProcessor: return PillowImageProcessor(max_workers=get_settings().processing_max_workers) -@lru_cache -def upload_rate_limiter() -> RateLimiter: - settings = get_settings() - return RateLimiter(settings.rate_limit_upload_max, settings.rate_limit_upload_window) +_rate_limiters: dict[str, RateLimiter] = {} -@lru_cache -def process_rate_limiter() -> RateLimiter: - settings = get_settings() - return RateLimiter(settings.rate_limit_process_max, settings.rate_limit_process_window) +def _get_rate_limiter(name: str, max_requests: int, window_seconds: int) -> RateLimiter: + if name not in _rate_limiters: + _rate_limiters[name] = RateLimiter(max_requests, window_seconds) + return _rate_limiters[name] -@lru_cache -def read_rate_limiter() -> RateLimiter: - settings = get_settings() - return RateLimiter(settings.rate_limit_read_max, settings.rate_limit_read_window) +async def upload_rate_limiter( + request: Request, + settings: Annotated[Settings, Depends(get_settings)] = None, # type: ignore[assignment] +) -> None: + limiter = _get_rate_limiter( + "upload", settings.rate_limit_upload_max, settings.rate_limit_upload_window + ) + await limiter(request) + + +async def process_rate_limiter( + request: Request, + settings: Annotated[Settings, Depends(get_settings)] = None, # type: ignore[assignment] +) -> None: + limiter = _get_rate_limiter( + "process", settings.rate_limit_process_max, settings.rate_limit_process_window + ) + await limiter(request) + + +async def read_rate_limiter( + request: Request, + settings: Annotated[Settings, Depends(get_settings)] = None, # type: ignore[assignment] +) -> None: + limiter = _get_rate_limiter( + "read", settings.rate_limit_read_max, settings.rate_limit_read_window + ) + await limiter(request) def get_upload_use_case() -> UploadImageUseCase: diff --git a/src/presentation/api/routes/images.py b/src/presentation/api/routes/images.py index 34c44a4..75b0bf3 100644 --- a/src/presentation/api/routes/images.py +++ b/src/presentation/api/routes/images.py @@ -49,7 +49,7 @@ async def upload_image( tags: Annotated[list[str] | None, Query()] = None, ttl_hours: Annotated[int | None, Query(ge=1, le=8760)] = None, use_case: Annotated[UploadImageUseCase, Depends(get_upload_use_case)] = None, # type: ignore[assignment] - _rate: Annotated[None, Depends(upload_rate_limiter())] = None, + _rate: Annotated[None, Depends(upload_rate_limiter)] = None, ): if file.content_type not in ALLOWED_CONTENT_TYPES: logger.warning("Upload rejected: unsupported content type %s", file.content_type) @@ -97,7 +97,7 @@ async def list_images( limit: Annotated[int, Query(ge=1, le=100)] = 50, status_filter: Annotated[str | None, Query(alias="status")] = None, use_case: Annotated[ListImagesUseCase, Depends(get_list_use_case)] = None, # type: ignore[assignment] - _rate: Annotated[None, Depends(read_rate_limiter())] = None, + _rate: Annotated[None, Depends(read_rate_limiter)] = None, ): return await use_case.execute(offset=offset, limit=limit, status=status_filter) @@ -106,7 +106,7 @@ async def list_images( async def get_image( image_id: uuid.UUID, use_case: Annotated[GetImageUseCase, Depends(get_get_image_use_case)] = None, # type: ignore[assignment] - _rate: Annotated[None, Depends(read_rate_limiter())] = None, + _rate: Annotated[None, Depends(read_rate_limiter)] = None, ): result = await use_case.execute(image_id) if result is None: @@ -120,7 +120,7 @@ async def download_image( image_id: uuid.UUID, thumbnail: bool = False, use_case: Annotated[GetImageUseCase, Depends(get_get_image_use_case)] = None, # type: ignore[assignment] - _rate: Annotated[None, Depends(read_rate_limiter())] = None, + _rate: Annotated[None, Depends(read_rate_limiter)] = None, ): data = await use_case.get_file(image_id, thumbnail=thumbnail) if data is None: @@ -133,7 +133,7 @@ async def download_image( async def process_batch_images( body: BatchProcessRequest, use_case: Annotated[ProcessImageUseCase, Depends(get_process_use_case)] = None, # type: ignore[assignment] - _rate: Annotated[None, Depends(process_rate_limiter())] = None, + _rate: Annotated[None, Depends(process_rate_limiter)] = None, ): result = await process_batch(use_case, body.image_ids, concurrency=body.concurrency) return result @@ -144,7 +144,7 @@ async def process_single_image( image_id: uuid.UUID, process_uc: Annotated[ProcessImageUseCase, Depends(get_process_use_case)] = None, # type: ignore[assignment] get_uc: Annotated[GetImageUseCase, Depends(get_get_image_use_case)] = None, # type: ignore[assignment] - _rate: Annotated[None, Depends(process_rate_limiter())] = None, + _rate: Annotated[None, Depends(process_rate_limiter)] = None, ): ok = await process_uc.execute(image_id) if not ok: diff --git a/src/presentation/api/routes/retention.py b/src/presentation/api/routes/retention.py index 25fa504..e6c6a2f 100644 --- a/src/presentation/api/routes/retention.py +++ b/src/presentation/api/routes/retention.py @@ -30,7 +30,7 @@ async def trigger_retention_sweep( use_case: Annotated[ApplyRetentionUseCase, Depends(get_retention_use_case)] = None, # type: ignore[assignment] settings: Annotated[Settings, Depends(get_settings)] = None, # type: ignore[assignment] - _rate: Annotated[None, Depends(process_rate_limiter())] = None, + _rate: Annotated[None, Depends(process_rate_limiter)] = None, ): logger.info("Retention sweep triggered, batch_size=%d", settings.retention_batch_size) result = await use_case.execute(batch_size=settings.retention_batch_size)