Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ 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.
`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`).
- 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

- `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
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions PROJECT_DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand All @@ -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 |
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
14 changes: 9 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion k8s/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions k8s/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,24 @@ 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
envFrom:
- configMapRef:
name: image-service-config
- secretRef:
name: image-service-db-credentials
resources:
requests:
cpu: "250m"
Expand All @@ -46,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: {}
9 changes: 9 additions & 0 deletions k8s/secret.yaml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion minikube/01-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions minikube/01a-secret.yaml
Original file line number Diff line number Diff line change
@@ -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
25 changes: 23 additions & 2 deletions minikube/02-postgres.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,31 @@ 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:
- 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"
Expand All @@ -56,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
Expand Down
13 changes: 13 additions & 0 deletions minikube/04-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,25 @@ 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
envFrom:
- configMapRef:
name: image-service-config
- secretRef:
name: image-service-db-credentials
resources:
requests:
cpu: "100m"
Expand All @@ -47,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: {}
3 changes: 2 additions & 1 deletion minikube/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions minikube/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
20 changes: 19 additions & 1 deletion src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from __future__ import annotations

from urllib.parse import quote_plus

from pydantic import computed_field
from pydantic_settings import BaseSettings


Expand All @@ -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"

Expand Down
Loading
Loading