From a08bfb9f1a21105c48356655e7bb5e129b7d8b43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:31:54 +0000 Subject: [PATCH] feat(helm): add Helm chart for Kubernetes deployment and update DeploymentGuide Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/tasks/embed_metadata_into_pdf.py | 4 +- app/utils/meilisearch_client.py | 7 +- docs/DeploymentGuide.md | 461 ++++++++++++------ helm/docuelevate/.helmignore | 10 + helm/docuelevate/Chart.yaml | 37 ++ helm/docuelevate/templates/NOTES.txt | 45 ++ helm/docuelevate/templates/_helpers.tpl | 108 ++++ .../docuelevate/templates/api-deployment.yaml | 93 ++++ helm/docuelevate/templates/api-service.yaml | 18 + helm/docuelevate/templates/configmap.yaml | 40 ++ helm/docuelevate/templates/gotenberg.yaml | 63 +++ helm/docuelevate/templates/hpa.yaml | 51 ++ helm/docuelevate/templates/ingress.yaml | 36 ++ helm/docuelevate/templates/meilisearch.yaml | 89 ++++ helm/docuelevate/templates/migration-job.yaml | 69 +++ helm/docuelevate/templates/pvc.yaml | 49 ++ .../docuelevate/templates/serviceaccount.yaml | 14 + .../templates/worker-deployment.yaml | 82 ++++ helm/docuelevate/values.yaml | 306 ++++++++++++ 19 files changed, 1424 insertions(+), 158 deletions(-) create mode 100644 helm/docuelevate/.helmignore create mode 100644 helm/docuelevate/Chart.yaml create mode 100644 helm/docuelevate/templates/NOTES.txt create mode 100644 helm/docuelevate/templates/_helpers.tpl create mode 100644 helm/docuelevate/templates/api-deployment.yaml create mode 100644 helm/docuelevate/templates/api-service.yaml create mode 100644 helm/docuelevate/templates/configmap.yaml create mode 100644 helm/docuelevate/templates/gotenberg.yaml create mode 100644 helm/docuelevate/templates/hpa.yaml create mode 100644 helm/docuelevate/templates/ingress.yaml create mode 100644 helm/docuelevate/templates/meilisearch.yaml create mode 100644 helm/docuelevate/templates/migration-job.yaml create mode 100644 helm/docuelevate/templates/pvc.yaml create mode 100644 helm/docuelevate/templates/serviceaccount.yaml create mode 100644 helm/docuelevate/templates/worker-deployment.yaml create mode 100644 helm/docuelevate/values.yaml diff --git a/app/tasks/embed_metadata_into_pdf.py b/app/tasks/embed_metadata_into_pdf.py index 73a4d0cf..3bc78664 100644 --- a/app/tasks/embed_metadata_into_pdf.py +++ b/app/tasks/embed_metadata_into_pdf.py @@ -200,8 +200,8 @@ def embed_metadata_into_pdf(self, local_file_path: str, extracted_text: str, met if metadata: try: file_record.ai_metadata = json.dumps(metadata, ensure_ascii=False) - except Exception: - pass + except Exception as json_exc: + logger.warning(f"[{task_id}] Could not serialise ai_metadata: {json_exc}") file_record.document_title = ( metadata.get("title") or metadata.get("filename") or file_record.original_filename ) diff --git a/app/utils/meilisearch_client.py b/app/utils/meilisearch_client.py index ff534785..cdc18942 100644 --- a/app/utils/meilisearch_client.py +++ b/app/utils/meilisearch_client.py @@ -8,7 +8,6 @@ Generation) workflows by storing full document text alongside structured metadata fields. """ -import json import logging from typing import Any, Optional @@ -112,7 +111,6 @@ def _get_or_create_index(client): def _build_document(file_record, text: str, metadata: dict) -> dict: """Build a Meilisearch document from a FileRecord and extracted content.""" - import json as _json tags = metadata.get("tags", []) if isinstance(tags, str): @@ -123,8 +121,8 @@ def _build_document(file_record, text: str, metadata: dict) -> dict: if file_record.created_at: try: created_at_ts = int(file_record.created_at.timestamp()) - except Exception: - pass + except Exception as ts_exc: # noqa: BLE001 + logger.debug(f"Could not convert created_at to timestamp: {ts_exc}") return { "file_id": file_record.id, @@ -229,7 +227,6 @@ def search_documents( return empty try: - from app.config import settings index = _get_or_create_index(client) diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index fb152e9c..cb66bfa9 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -1,40 +1,48 @@ # Deployment Guide -This guide provides instructions for deploying DocuElevate in various environments. +This guide covers all supported deployment methods for DocuElevate. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Docker Compose Deployment](#docker-compose-deployment) *(recommended for single-server)* +- [Kubernetes / Helm Deployment](#kubernetes--helm-deployment) *(recommended for production scale-out)* +- [Production Considerations](#production-considerations) +- [Scaling](#scaling) +- [Backup Procedures](#backup-procedures) +- [Updates](#updates) +- [Troubleshooting](#troubleshooting) ## Prerequisites -- Docker and Docker Compose +- Docker and Docker Compose **or** a Kubernetes cluster with Helm 3 - Access to required external services (if configured): - AI provider API key (OpenAI, Anthropic, Gemini, or other configured provider) - Azure Document Intelligence - - Dropbox API - - Nextcloud instance - - Paperless NGX instance - - SMTP server (for email notifications) - - IMAP server(s) (for email attachment processing) - - Notification services (Discord, Telegram, etc. for system alerts) + - Dropbox, Google Drive, OneDrive, S3, or other storage APIs + - SMTP / IMAP server (for email processing) + - Notification services (Discord, Telegram, etc.) -## Docker Deployment +--- -Docker is the recommended deployment method for DocuElevate. +## Docker Compose Deployment + +Docker Compose is the quickest way to run DocuElevate on a single server. ### Step 1: Clone the Repository ```bash -git clone https://github.com/christianlouis/document-processor.git -cd document-processor +git clone https://github.com/christianlouis/DocuElevate.git +cd DocuElevate ``` ### Step 2: Configure Environment Variables -Create a `.env` file based on the example: - ```bash -cp .env.example .env +cp .env.demo .env ``` -Edit the `.env` file with your configuration settings. See the [Configuration Guide](ConfigurationGuide.md) for details. +Edit `.env` with your settings. See the [Configuration Guide](ConfigurationGuide.md) for all options. ### Step 3: Run with Docker Compose @@ -42,41 +50,237 @@ Edit the `.env` file with your configuration settings. See the [Configuration Gu docker-compose up -d ``` -This will start: -- The DocuElevate API server -- A worker for background tasks -- Redis for message broker and result storage -- Gotenberg for PDF processing +This starts: + +| Service | Purpose | +|---------|---------| +| `api` | FastAPI web server (port 8000) | +| `worker` | Celery background task worker | +| `redis` | Message broker for Celery | +| `gotenberg` | PDF conversion (LibreOffice headless) | +| `meilisearch` | Full-text search engine (port 7700) | ### Step 4: Verify the Installation -Access the web interface at `http://localhost:8000` and the API documentation at `http://localhost:8000/docs`. +Access the web interface at `http://localhost:8000` and the API docs at `http://localhost:8000/docs`. + +--- + +## Kubernetes / Helm Deployment + +The Helm chart at `helm/docuelevate/` packages all components into a single, configurable release. It supports: + +- Multiple replicas for the API and Worker +- Horizontal Pod Autoscaling (HPA) +- Bundled or external Redis +- Persistent volumes for workdir and Meilisearch data +- Alembic database migration Job (pre-install/upgrade hook) +- TLS Ingress via any controller (nginx, Traefik, etc.) + +### Prerequisites + +- Kubernetes 1.24+ +- Helm 3.10+ +- A storage class that supports **ReadWriteMany** (e.g. NFS, CephFS, Azure Files, EFS) for the shared workdir PVC when running multiple replicas. Single-replica clusters can use `ReadWriteOnce`. +- A PostgreSQL database (strongly recommended over SQLite for multi-replica). + +### Quick Start + +```bash +# 1. Add the Bitnami chart repository (needed for bundled Redis) +helm repo add bitnami https://charts.bitnami.com/bitnami +helm repo update + +# 2. Update chart dependencies +helm dependency update ./helm/docuelevate + +# 3. Install with a minimal values override +helm install docuelevate ./helm/docuelevate \ + --namespace docuelevate --create-namespace \ + --set secrets.DATABASE_URL="postgresql://user:pass@postgres:5432/docuelevate" \ + --set secrets.SESSION_SECRET="$(openssl rand -hex 32)" \ + --set secrets.OPENAI_API_KEY="sk-..." \ + --set secrets.AZURE_AI_KEY="..." \ + --set env.AZURE_ENDPOINT="https://my-resource.cognitiveservices.azure.com/" \ + --set env.EXTERNAL_HOSTNAME="docuelevate.example.com" +``` + +### Values Reference + +The full list of configurable values is in [`helm/docuelevate/values.yaml`](../helm/docuelevate/values.yaml). Key sections: + +#### Container Image + +```yaml +image: + repository: ghcr.io/christianlouis/docuelevate + tag: "" # defaults to chart appVersion + pullPolicy: IfNotPresent +``` + +#### Non-Secret Config (`env`) + +```yaml +env: + WORKDIR: /workdir + AI_PROVIDER: openai + OPENAI_MODEL: gpt-4o-mini + AZURE_REGION: eastus + AZURE_ENDPOINT: "https://my-resource.cognitiveservices.azure.com/" + MEILISEARCH_URL: http://docuelevate-meilisearch:7700 # auto-resolved from service name + ENABLE_SEARCH: "true" + AUTH_ENABLED: "true" + EXTERNAL_HOSTNAME: docuelevate.example.com +``` + +#### Secrets (`secrets`) + +All secrets are stored in a Kubernetes `Secret` and injected as environment variables. + +```yaml +secrets: + DATABASE_URL: "postgresql://user:pass@postgres:5432/docuelevate" + SESSION_SECRET: "" + OPENAI_API_KEY: "sk-..." + AZURE_AI_KEY: "..." + MEILISEARCH_API_KEY: "" # leave blank for unauthenticated dev Meilisearch + # Storage provider secrets ... +``` + +> **Tip:** In production use an external secret manager (Vault, ESO, Sealed Secrets) and reference the secret by name instead of embedding values in values.yaml. + +#### Replicas & Autoscaling + +```yaml +api: + replicaCount: 2 + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 8 + targetCPUUtilizationPercentage: 70 + +worker: + replicaCount: 2 + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 75 +``` + +#### Shared Workdir PVC + +```yaml +workdir: + persistence: + enabled: true + accessMode: ReadWriteMany # RWX required for multi-replica + size: 20Gi + storageClass: "nfs-client" # or leave blank for cluster default +``` + +#### Ingress (nginx example) + +```yaml +ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: "1g" + cert-manager.io/cluster-issuer: letsencrypt-prod + hosts: + - host: docuelevate.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: docuelevate-tls + hosts: + - docuelevate.example.com +``` + +#### External Redis + +```yaml +redis: + enabled: false # disable bundled Redis + +externalRedis: + url: "redis://my-redis-host:6379/0" +``` + +#### Meilisearch + +The bundled Meilisearch deployment is a single-replica, persistent StatefulSet-equivalent. For production, consider [Meilisearch Cloud](https://www.meilisearch.com/cloud) and point `env.MEILISEARCH_URL` at it. + +```yaml +meilisearch: + enabled: true + persistence: + enabled: true + size: 10Gi +``` + +### Upgrading + +```bash +helm upgrade docuelevate ./helm/docuelevate \ + --namespace docuelevate \ + -f my-values.yaml +``` + +The pre-upgrade hook runs `alembic upgrade head` automatically before the new pods start. + +### Uninstalling + +```bash +helm uninstall docuelevate --namespace docuelevate +# PVCs are NOT deleted automatically — remove manually if desired: +kubectl delete pvc -l app.kubernetes.io/instance=docuelevate -n docuelevate +``` + +### Kubernetes Architecture Diagram + +``` +Internet + │ + ▼ +[Ingress / LoadBalancer] + │ + ▼ +[API Deployment] ─────── [Worker Deployment] + │ │ │ │ + │ └── shared PVC ──┘ │ + │ (workdir) │ + ▼ ▼ +[Redis Service] [Gotenberg Service] + │ +[Meilisearch Service] +``` + +--- ## Production Considerations -### Security Headers +### Database -DocuElevate includes built-in support for HTTP security headers to improve browser-side security. **These headers are disabled by default** since most deployments use a reverse proxy (Traefik, Nginx, etc.) that already adds these headers. +SQLite is fine for development but **not recommended for multi-replica production** deployments because it cannot be shared safely across pods. Use PostgreSQL: -#### Supported Security Headers - -- **Strict-Transport-Security (HSTS)**: Forces browsers to use HTTPS for all future requests -- **Content-Security-Policy (CSP)**: Controls which resources browsers are allowed to load -- **X-Frame-Options**: Prevents the page from being loaded in frames (clickjacking protection) -- **X-Content-Type-Options**: Prevents browsers from MIME-sniffing responses - -#### Reverse Proxy Deployment (Traefik, Nginx, etc.) - DEFAULT - -**Most deployments use a reverse proxy**, which is why security headers are disabled by default in DocuElevate. The reverse proxy should add these headers. - -```bash -# In .env file (or omit - this is the default) -SECURITY_HEADERS_ENABLED=false +``` +DATABASE_URL=postgresql://docuelevate:secret@postgres-host:5432/docuelevate ``` -##### Traefik Configuration Example +### Security Headers -Traefik can add security headers using middleware. Create a `docker-compose.yaml` with Traefik labels: +DocuElevate's built-in security headers are **disabled by default** since most deployments use a reverse proxy that already adds them. + +```bash +# Enable only if running without a reverse proxy +SECURITY_HEADERS_ENABLED=true +``` + +#### Traefik (Docker Compose) example ```yaml services: @@ -85,35 +289,25 @@ services: - "traefik.enable=true" - "traefik.http.routers.docuelevate.rule=Host(`docuelevate.example.com`)" - "traefik.http.routers.docuelevate.entrypoints=websecure" - - "traefik.http.routers.docuelevate.tls=true" - "traefik.http.routers.docuelevate.tls.certresolver=letsencrypt" - # Security headers middleware - "traefik.http.routers.docuelevate.middlewares=security-headers@docker" - "traefik.http.middlewares.security-headers.headers.stsSeconds=31536000" - "traefik.http.middlewares.security-headers.headers.stsIncludeSubdomains=true" - - "traefik.http.middlewares.security-headers.headers.contentSecurityPolicy=default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;" - - "traefik.http.middlewares.security-headers.headers.customFrameOptionsValue=DENY" - "traefik.http.middlewares.security-headers.headers.contentTypeNosniff=true" + - "traefik.http.middlewares.security-headers.headers.customFrameOptionsValue=DENY" ``` -Then set `SECURITY_HEADERS_ENABLED=false` in your `.env` file. - -##### Nginx Configuration Example - -Add security headers to your Nginx configuration: +#### Nginx example ```nginx server { listen 443 ssl http2; server_name docuelevate.example.com; - # SSL configuration ssl_certificate /etc/nginx/ssl/cert.pem; ssl_certificate_key /etc/nginx/ssl/key.pem; - # Security headers add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;" always; add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; @@ -123,144 +317,109 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 1g; } } ``` -Then keep `SECURITY_HEADERS_ENABLED=false` in your `.env` file (or omit it, as this is the default). - -#### Direct Deployment (No Reverse Proxy) - -If you're running DocuElevate **directly without a reverse proxy**, enable security headers: - -```bash -# In .env file -SECURITY_HEADERS_ENABLED=true -``` - -You can also configure individual headers: - -```bash -SECURITY_HEADER_HSTS_ENABLED=true -SECURITY_HEADER_CSP_ENABLED=true -SECURITY_HEADER_X_FRAME_OPTIONS_ENABLED=true -SECURITY_HEADER_X_CONTENT_TYPE_OPTIONS_ENABLED=true -``` - -**Note**: HSTS only works when serving content over HTTPS. If using HTTP for development, you can disable it: - -```bash -SECURITY_HEADER_HSTS_ENABLED=false -``` - -#### Customizing Security Headers - -If you enable security headers, you can customize individual header values in your `.env` file: - -```bash -# Customize HSTS (e.g., shorter duration for testing) -SECURITY_HEADER_HSTS_VALUE="max-age=300" - -# Customize CSP (e.g., allow specific external domains) -SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline';" - -# Allow framing from same origin -SECURITY_HEADER_X_FRAME_OPTIONS_VALUE="SAMEORIGIN" -``` - -#### Security Considerations - -1. **HSTS and HTTPS**: HSTS only works over HTTPS. Ensure you have a valid SSL certificate before enabling HSTS. -2. **CSP Testing**: The default CSP policy allows inline scripts and styles for compatibility. Test thoroughly before tightening. -3. **Content-Security-Policy**: The default policy allows `'unsafe-inline'` for scripts and styles for compatibility with Tailwind CSS and inline JavaScript. For stricter security, consider using nonces or hashes. -4. **X-Frame-Options**: Set to `DENY` by default. Change to `SAMEORIGIN` if you need to embed DocuElevate in iframes on the same domain. - -See the [Configuration Guide](ConfigurationGuide.md) for all security header options. - -### Reverse Proxy Setup - -For production use, we recommend setting up a reverse proxy (like Nginx or Traefik) to handle HTTPS and domain routing: - -```nginx -server { - listen 80; - server_name docuelevate.example.com; - - location / { - proxy_pass http://localhost:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} -``` - -### Persistent Storage - -The Docker setup uses volumes for persistent storage. For production, consider: +### Storage ```yaml +# Docker Compose volumes: - /path/to/persistent/storage:/workdir + +# Helm — use a RWX storage class for multi-replica +workdir: + persistence: + size: 20Gi + accessMode: ReadWriteMany + storageClass: "nfs-client" ``` -### Security +### General Security Checklist 1. **Always use HTTPS** in production -2. Enable authentication by setting `AUTH_ENABLED=true` -3. Use strong passwords for all services -4. Limit access to the Docker host -5. Regularly update the application and dependencies +2. Set `AUTH_ENABLED=true` and use a strong `SESSION_SECRET` +3. Rotate API keys and secrets regularly — see the [Credential Rotation Guide](CredentialRotationGuide.md) +4. Limit network access to Redis and Meilisearch (both should be internal-only) +5. Regularly update the container image to pick up dependency patches + +--- ## Scaling -For high-volume deployments: +### Docker Compose -1. Increase worker processes by adding more worker containers: +Add more worker containers: ```yaml worker: - image: christianlouis/document-processor:latest deploy: replicas: 3 ``` -2. Consider using dedicated Redis and database servers -3. Monitor system performance and adjust resources as needed +### Kubernetes / Helm + +Enable HPA: + +```yaml +api: + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 8 + +worker: + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 +``` + +--- ## Monitoring -Monitor your DocuElevate deployment using: +- **Docker Compose**: `docker-compose logs -f`, `docker stats` +- **Kubernetes**: `kubectl logs -l app.kubernetes.io/component=api -f` +- **Prometheus / Grafana**: Scrape the `/api/health` endpoint for readiness; add custom metrics as needed. +- **Uptime Kuma**: Set `UPTIME_KUMA_URL` to your push URL for heartbeat monitoring. -- Docker's built-in logging: `docker-compose logs -f` -- Container metrics: `docker stats` -- External monitoring tools like Prometheus and Grafana +--- ## Backup Procedures -Regularly back up the following: +Regularly back up: -1. The `/workdir` directory containing all processed documents -2. The database file (if using SQLite) or database contents (if using another DBMS) -3. The `.env` configuration file +1. The `/workdir` volume (all processed documents and originals) +2. The database (PostgreSQL `pg_dump` or SQLite file) +3. The Meilisearch data directory (`/meili_data`) +4. Your `.env` / Helm values file (store securely, it contains secrets) + +--- ## Updates -To update DocuElevate to a newer version: +### Docker Compose ```bash -# Pull the latest changes git pull - -# Pull the latest Docker images docker-compose pull - -# Restart the services -docker-compose down -docker-compose up -d +docker-compose down && docker-compose up -d ``` +### Helm + +```bash +helm repo update # if using a hosted chart repository +helm upgrade docuelevate ./helm/docuelevate --namespace docuelevate -f my-values.yaml +``` + +The migration Job runs automatically on every `helm upgrade`. + +--- + ## Troubleshooting -See the [Troubleshooting](Troubleshooting.md) guide for common deployment issues and solutions. +See the [Troubleshooting Guide](Troubleshooting.md) for common issues and solutions. diff --git a/helm/docuelevate/.helmignore b/helm/docuelevate/.helmignore new file mode 100644 index 00000000..cdb7f322 --- /dev/null +++ b/helm/docuelevate/.helmignore @@ -0,0 +1,10 @@ +# Patterns to ignore when building packages. +.DS_Store +.git +.gitignore +*.swp +*.bak +*.tmp +*.orig +*~ +.vscode diff --git a/helm/docuelevate/Chart.yaml b/helm/docuelevate/Chart.yaml new file mode 100644 index 00000000..7da29b15 --- /dev/null +++ b/helm/docuelevate/Chart.yaml @@ -0,0 +1,37 @@ +apiVersion: v2 +name: docuelevate +description: > + DocuElevate — intelligent document processing with OCR, AI metadata + extraction, full-text search (Meilisearch), and multi-cloud storage. + +type: application + +# Chart version — bump on every chart change (independent of appVersion). +version: 0.1.0 + +# Application version — kept in sync with the VERSION file. +appVersion: "0.54.0" + +keywords: + - document-management + - ocr + - ai + - meilisearch + - fastapi + - celery + +home: https://github.com/christianlouis/DocuElevate +sources: + - https://github.com/christianlouis/DocuElevate + +maintainers: + - name: DocuElevate Contributors + url: https://github.com/christianlouis/DocuElevate + +dependencies: + # Bundled Redis (optional — disable and point to an external instance via + # externalRedis.url if you already have Redis in the cluster). + - name: redis + version: "20.x.x" + repository: "https://charts.bitnami.com/bitnami" + condition: redis.enabled diff --git a/helm/docuelevate/templates/NOTES.txt b/helm/docuelevate/templates/NOTES.txt new file mode 100644 index 00000000..57c14773 --- /dev/null +++ b/helm/docuelevate/templates/NOTES.txt @@ -0,0 +1,45 @@ +Thank you for installing {{ .Chart.Name }} {{ .Chart.Version }}! + +{{- if .Values.ingress.enabled }} +The application will be available at: +{{- range .Values.ingress.hosts }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }} +{{- end }} +{{- else }} +To access DocuElevate, run: + + kubectl port-forward svc/{{ include "docuelevate.fullname" . }}-api {{ .Values.api.service.port }}:{{ .Values.api.service.port }} -n {{ .Release.Namespace }} + +Then open http://localhost:{{ .Values.api.service.port }} +{{- end }} + +API docs are available at: /docs + +{{- if not .Values.secrets.SESSION_SECRET }} + +⚠️ WARNING: secrets.SESSION_SECRET is not set. + Generate one with: openssl rand -hex 32 + Then pass it via: --set secrets.SESSION_SECRET= + or via a values file / external secret. + +{{- end }} + +{{- if not .Values.secrets.DATABASE_URL }} + +⚠️ WARNING: secrets.DATABASE_URL is not set. + For production, use PostgreSQL: + postgresql://user:pass@host:5432/docuelevate + SQLite with a PVC is supported but not recommended for multi-replica + deployments (use ReadWriteOnce PVC and replicaCount=1 in that case). + +{{- end }} + +Useful commands: + # View API logs + kubectl logs -l app.kubernetes.io/component=api -n {{ .Release.Namespace }} -f + + # View worker logs + kubectl logs -l app.kubernetes.io/component=worker -n {{ .Release.Namespace }} -f + + # Run a database migration manually + kubectl create job --from=cronjob/{{ include "docuelevate.fullname" . }}-migrate migrate-manual -n {{ .Release.Namespace }} diff --git a/helm/docuelevate/templates/_helpers.tpl b/helm/docuelevate/templates/_helpers.tpl new file mode 100644 index 00000000..9e825715 --- /dev/null +++ b/helm/docuelevate/templates/_helpers.tpl @@ -0,0 +1,108 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "docuelevate.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this. +If release name contains chart name it will be used as a full name. +*/}} +{{- define "docuelevate.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart label value: "chart-name-version" +*/}} +{{- define "docuelevate.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels applied to every resource. +*/}} +{{- define "docuelevate.labels" -}} +helm.sh/chart: {{ include "docuelevate.chart" . }} +{{ include "docuelevate.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels. +*/}} +{{- define "docuelevate.selectorLabels" -}} +app.kubernetes.io/name: {{ include "docuelevate.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +ServiceAccount name. +*/}} +{{- define "docuelevate.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "docuelevate.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Image reference, using appVersion as default tag. +*/}} +{{- define "docuelevate.image" -}} +{{- $tag := .Values.image.tag | default .Chart.AppVersion }} +{{- printf "%s:%s" .Values.image.repository $tag }} +{{- end }} + +{{/* +Resolve REDIS_URL: prefer externalRedis.url, then secrets.REDIS_URL, +then fall back to the bundled Redis service URL. +*/}} +{{- define "docuelevate.redisUrl" -}} +{{- if .Values.externalRedis.url }} +{{- .Values.externalRedis.url }} +{{- else if .Values.secrets.REDIS_URL }} +{{- .Values.secrets.REDIS_URL }} +{{- else if .Values.redis.enabled }} +{{- printf "redis://%s-redis-master:6379/0" .Release.Name }} +{{- else }} +{{- "" }} +{{- end }} +{{- end }} + +{{/* +Resolve GOTENBERG_URL — use env override if set, otherwise build from service name. +*/}} +{{- define "docuelevate.gotenbergUrl" -}} +{{- if .Values.env.GOTENBERG_URL }} +{{- tpl .Values.env.GOTENBERG_URL . }} +{{- else }} +{{- printf "http://%s-gotenberg:3000" (include "docuelevate.fullname" .) }} +{{- end }} +{{- end }} + +{{/* +Resolve MEILISEARCH_URL — use env override if set, otherwise build from service name. +*/}} +{{- define "docuelevate.meilisearchUrl" -}} +{{- if .Values.env.MEILISEARCH_URL }} +{{- tpl .Values.env.MEILISEARCH_URL . }} +{{- else }} +{{- printf "http://%s-meilisearch:7700" (include "docuelevate.fullname" .) }} +{{- end }} +{{- end }} diff --git a/helm/docuelevate/templates/api-deployment.yaml b/helm/docuelevate/templates/api-deployment.yaml new file mode 100644 index 00000000..3fca1d50 --- /dev/null +++ b/helm/docuelevate/templates/api-deployment.yaml @@ -0,0 +1,93 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "docuelevate.fullname" . }}-api + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + {{- if not .Values.api.autoscaling.enabled }} + replicas: {{ .Values.api.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "docuelevate.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: api + template: + metadata: + labels: + {{- include "docuelevate.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: api + {{- with .Values.api.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "docuelevate.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: api + image: {{ include "docuelevate.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - uvicorn + - app.main:app + - --host + - "0.0.0.0" + - --port + - "8000" + - --proxy-headers + ports: + - name: http + containerPort: 8000 + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "docuelevate.fullname" . }}-config + - secretRef: + name: {{ include "docuelevate.fullname" . }}-secret + {{- with .Values.api.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.api.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.api.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.api.resources | nindent 12 }} + volumeMounts: + - name: workdir + mountPath: /workdir + volumes: + - name: workdir + {{- if .Values.workdir.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ .Values.workdir.persistence.existingClaim | default (printf "%s-workdir" (include "docuelevate.fullname" .)) }} + {{- else }} + emptyDir: {} + {{- end }} + {{- with .Values.api.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/docuelevate/templates/api-service.yaml b/helm/docuelevate/templates/api-service.yaml new file mode 100644 index 00000000..038b9a48 --- /dev/null +++ b/helm/docuelevate/templates/api-service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "docuelevate.fullname" . }}-api + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + type: {{ .Values.api.service.type }} + ports: + - port: {{ .Values.api.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "docuelevate.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: api diff --git a/helm/docuelevate/templates/configmap.yaml b/helm/docuelevate/templates/configmap.yaml new file mode 100644 index 00000000..df028e5b --- /dev/null +++ b/helm/docuelevate/templates/configmap.yaml @@ -0,0 +1,40 @@ +# Non-sensitive application configuration. +# All values are injected as environment variables into the API and Worker pods. +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "docuelevate.fullname" . }}-config + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} +data: + WORKDIR: {{ .Values.env.WORKDIR | quote }} + PYTHONPATH: "/app" + PYTHONUNBUFFERED: "1" + + # AI provider + AI_PROVIDER: {{ .Values.env.AI_PROVIDER | quote }} + OPENAI_MODEL: {{ .Values.env.OPENAI_MODEL | quote }} + + # Azure + AZURE_REGION: {{ .Values.env.AZURE_REGION | quote }} + AZURE_ENDPOINT: {{ .Values.env.AZURE_ENDPOINT | quote }} + + # Service URLs — resolved from helper templates so they always match the + # in-cluster service names even if values.yaml uses template expressions. + GOTENBERG_URL: {{ include "docuelevate.gotenbergUrl" . | quote }} + MEILISEARCH_URL: {{ include "docuelevate.meilisearchUrl" . | quote }} + MEILISEARCH_INDEX_NAME: {{ .Values.env.MEILISEARCH_INDEX_NAME | quote }} + ENABLE_SEARCH: {{ .Values.env.ENABLE_SEARCH | quote }} + + # Auth + AUTH_ENABLED: {{ .Values.env.AUTH_ENABLED | quote }} + ADMIN_USERNAME: {{ .Values.env.ADMIN_USERNAME | quote }} + EXTERNAL_HOSTNAME: {{ .Values.env.EXTERNAL_HOSTNAME | quote }} + + # Feature flags + ENABLE_DEDUPLICATION: {{ .Values.env.ENABLE_DEDUPLICATION | quote }} + ENABLE_TEXT_QUALITY_CHECK: {{ .Values.env.ENABLE_TEXT_QUALITY_CHECK | quote }} + ALLOW_FILE_DELETE: {{ .Values.env.ALLOW_FILE_DELETE | quote }} + + DEBUG: {{ .Values.env.DEBUG | quote }} diff --git a/helm/docuelevate/templates/gotenberg.yaml b/helm/docuelevate/templates/gotenberg.yaml new file mode 100644 index 00000000..599bb4ae --- /dev/null +++ b/helm/docuelevate/templates/gotenberg.yaml @@ -0,0 +1,63 @@ +{{- if .Values.gotenberg.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "docuelevate.fullname" . }}-gotenberg + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} + app.kubernetes.io/component: gotenberg +spec: + replicas: 1 + selector: + matchLabels: + {{- include "docuelevate.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: gotenberg + template: + metadata: + labels: + {{- include "docuelevate.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: gotenberg + spec: + containers: + - name: gotenberg + image: {{ .Values.gotenberg.image.repository }}:{{ .Values.gotenberg.image.tag }} + imagePullPolicy: {{ .Values.gotenberg.image.pullPolicy }} + ports: + - name: http + containerPort: 3000 + protocol: TCP + resources: + {{- toYaml .Values.gotenberg.resources | nindent 12 }} + livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "docuelevate.fullname" . }}-gotenberg + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} + app.kubernetes.io/component: gotenberg +spec: + type: ClusterIP + ports: + - port: {{ .Values.gotenberg.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "docuelevate.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: gotenberg +{{- end }} diff --git a/helm/docuelevate/templates/hpa.yaml b/helm/docuelevate/templates/hpa.yaml new file mode 100644 index 00000000..0de67aa9 --- /dev/null +++ b/helm/docuelevate/templates/hpa.yaml @@ -0,0 +1,51 @@ +{{- if .Values.api.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "docuelevate.fullname" . }}-api + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "docuelevate.fullname" . }}-api + minReplicas: {{ .Values.api.autoscaling.minReplicas }} + maxReplicas: {{ .Values.api.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.api.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} + +--- + +{{- if .Values.worker.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "docuelevate.fullname" . }}-worker + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "docuelevate.fullname" . }}-worker + minReplicas: {{ .Values.worker.autoscaling.minReplicas }} + maxReplicas: {{ .Values.worker.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.worker.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} diff --git a/helm/docuelevate/templates/ingress.yaml b/helm/docuelevate/templates/ingress.yaml new file mode 100644 index 00000000..33301d2c --- /dev/null +++ b/helm/docuelevate/templates/ingress.yaml @@ -0,0 +1,36 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "docuelevate.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- toYaml .Values.ingress.tls | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "docuelevate.fullname" $ }}-api + port: + number: {{ $.Values.api.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm/docuelevate/templates/meilisearch.yaml b/helm/docuelevate/templates/meilisearch.yaml new file mode 100644 index 00000000..7b5e5189 --- /dev/null +++ b/helm/docuelevate/templates/meilisearch.yaml @@ -0,0 +1,89 @@ +{{- if .Values.meilisearch.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "docuelevate.fullname" . }}-meilisearch + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} + app.kubernetes.io/component: meilisearch +spec: + # Single replica — Meilisearch Community Edition is single-node only. + # Use Meilisearch Cloud or the paid Meilisearch Cluster for HA. + replicas: 1 + selector: + matchLabels: + {{- include "docuelevate.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: meilisearch + strategy: + # Recreate ensures the PVC is released before the new pod starts + type: Recreate + template: + metadata: + labels: + {{- include "docuelevate.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: meilisearch + spec: + containers: + - name: meilisearch + image: {{ .Values.meilisearch.image.repository }}:{{ .Values.meilisearch.image.tag }} + imagePullPolicy: {{ .Values.meilisearch.image.pullPolicy }} + ports: + - name: http + containerPort: 7700 + protocol: TCP + env: + - name: MEILI_NO_ANALYTICS + value: {{ .Values.meilisearch.env.MEILI_NO_ANALYTICS | quote }} + {{- if .Values.secrets.MEILISEARCH_API_KEY }} + - name: MEILI_MASTER_KEY + valueFrom: + secretKeyRef: + name: {{ include "docuelevate.fullname" . }}-secret + key: MEILISEARCH_API_KEY + {{- end }} + resources: + {{- toYaml .Values.meilisearch.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /meili_data + livenessProbe: + httpGet: + path: /health + port: 7700 + initialDelaySeconds: 15 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /health + port: 7700 + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: data + {{- if .Values.meilisearch.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ .Values.meilisearch.persistence.existingClaim | default (printf "%s-meilisearch-data" (include "docuelevate.fullname" .)) }} + {{- else }} + emptyDir: {} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "docuelevate.fullname" . }}-meilisearch + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} + app.kubernetes.io/component: meilisearch +spec: + type: ClusterIP + ports: + - port: {{ .Values.meilisearch.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "docuelevate.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: meilisearch +{{- end }} diff --git a/helm/docuelevate/templates/migration-job.yaml b/helm/docuelevate/templates/migration-job.yaml new file mode 100644 index 00000000..e8c5949b --- /dev/null +++ b/helm/docuelevate/templates/migration-job.yaml @@ -0,0 +1,69 @@ +# Database migration Job — runs `alembic upgrade head` once before +# the API and Worker Deployments start. Helm hooks ensure ordering: +# the Job runs during pre-upgrade/pre-install and the Deployments wait +# for it via the `helm.sh/hook-weight` annotation. +{{- if .Values.migrations.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "docuelevate.fullname" . }}-migrate-{{ .Release.Revision }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} + app.kubernetes.io/component: migrate + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": before-hook-creation + {{- if .Values.migrations.ttlSecondsAfterFinished }} +spec: + ttlSecondsAfterFinished: {{ .Values.migrations.ttlSecondsAfterFinished }} + {{- end }} +spec: + {{- if .Values.migrations.ttlSecondsAfterFinished }} + ttlSecondsAfterFinished: {{ .Values.migrations.ttlSecondsAfterFinished }} + {{- end }} + backoffLimit: 3 + template: + metadata: + labels: + {{- include "docuelevate.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: migrate + spec: + restartPolicy: OnFailure + serviceAccountName: {{ include "docuelevate.serviceAccountName" . }} + {{- with .Values.image.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: migrate + image: {{ include "docuelevate.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["alembic", "upgrade", "head"] + envFrom: + - configMapRef: + name: {{ include "docuelevate.fullname" . }}-config + - secretRef: + name: {{ include "docuelevate.fullname" . }}-secret + {{- with .Values.workdir.persistence }} + volumeMounts: + - name: workdir + mountPath: /workdir + {{- end }} + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: workdir + {{- if .Values.workdir.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ .Values.workdir.persistence.existingClaim | default (printf "%s-workdir" (include "docuelevate.fullname" .)) }} + {{- else }} + emptyDir: {} + {{- end }} +{{- end }} diff --git a/helm/docuelevate/templates/pvc.yaml b/helm/docuelevate/templates/pvc.yaml new file mode 100644 index 00000000..aec19c05 --- /dev/null +++ b/helm/docuelevate/templates/pvc.yaml @@ -0,0 +1,49 @@ +# Shared workdir PVC — mounted by both the API and the Worker so they can +# exchange files during processing. Requires ReadWriteMany (e.g. NFS, +# CephFS, Azure Files, EFS) when running multiple replicas. +# Set workdir.persistence.accessMode=ReadWriteOnce and replicaCount=1 +# for single-node / dev clusters. +{{- if .Values.workdir.persistence.enabled }} +{{- if not .Values.workdir.persistence.existingClaim }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "docuelevate.fullname" . }}-workdir + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} +spec: + accessModes: + - {{ .Values.workdir.persistence.accessMode }} + resources: + requests: + storage: {{ .Values.workdir.persistence.size }} + {{- if .Values.workdir.persistence.storageClass }} + storageClassName: {{ .Values.workdir.persistence.storageClass }} + {{- end }} +{{- end }} +{{- end }} + +--- + +# Meilisearch data PVC +{{- if and .Values.meilisearch.enabled .Values.meilisearch.persistence.enabled }} +{{- if not .Values.meilisearch.persistence.existingClaim }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "docuelevate.fullname" . }}-meilisearch-data + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} +spec: + accessModes: + - {{ .Values.meilisearch.persistence.accessMode }} + resources: + requests: + storage: {{ .Values.meilisearch.persistence.size }} + {{- if .Values.meilisearch.persistence.storageClass }} + storageClassName: {{ .Values.meilisearch.persistence.storageClass }} + {{- end }} +{{- end }} +{{- end }} diff --git a/helm/docuelevate/templates/serviceaccount.yaml b/helm/docuelevate/templates/serviceaccount.yaml new file mode 100644 index 00000000..efbf6b86 --- /dev/null +++ b/helm/docuelevate/templates/serviceaccount.yaml @@ -0,0 +1,14 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "docuelevate.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: false +{{- end }} diff --git a/helm/docuelevate/templates/worker-deployment.yaml b/helm/docuelevate/templates/worker-deployment.yaml new file mode 100644 index 00000000..c698b0f0 --- /dev/null +++ b/helm/docuelevate/templates/worker-deployment.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "docuelevate.fullname" . }}-worker + namespace: {{ .Release.Namespace }} + labels: + {{- include "docuelevate.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + {{- if not .Values.worker.autoscaling.enabled }} + replicas: {{ .Values.worker.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "docuelevate.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: worker + template: + metadata: + labels: + {{- include "docuelevate.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: worker + {{- with .Values.worker.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "docuelevate.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: worker + image: {{ include "docuelevate.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - celery + - -A + - app.celery_worker + - worker + - -B + - --loglevel=info + - -Q + - document_processor,default,celery + envFrom: + - configMapRef: + name: {{ include "docuelevate.fullname" . }}-config + - secretRef: + name: {{ include "docuelevate.fullname" . }}-secret + {{- with .Values.worker.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.worker.resources | nindent 12 }} + volumeMounts: + - name: workdir + mountPath: /workdir + volumes: + - name: workdir + {{- if .Values.workdir.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ .Values.workdir.persistence.existingClaim | default (printf "%s-workdir" (include "docuelevate.fullname" .)) }} + {{- else }} + emptyDir: {} + {{- end }} + {{- with .Values.worker.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/docuelevate/values.yaml b/helm/docuelevate/values.yaml new file mode 100644 index 00000000..7ed056b0 --- /dev/null +++ b/helm/docuelevate/values.yaml @@ -0,0 +1,306 @@ +# ============================================================================= +# DocuElevate Helm Chart — values.yaml +# +# Override any value with: +# helm install docuelevate ./helm/docuelevate -f my-values.yaml +# helm install docuelevate ./helm/docuelevate --set key=value +# ============================================================================= + +# --------------------------------------------------------------------------- +# Global image settings +# --------------------------------------------------------------------------- +image: + repository: ghcr.io/christianlouis/docuelevate + # Defaults to the chart appVersion; pin to a specific digest in production. + tag: "" + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +nameOverride: "" +fullnameOverride: "" + +# --------------------------------------------------------------------------- +# Shared environment — non-secret application config +# All values map 1-to-1 onto DocuElevate environment variables. +# Sensitive values (API keys, passwords) go into `secrets` below. +# --------------------------------------------------------------------------- +env: + # Required ---------------------------------------------------------------- + WORKDIR: /workdir + + # AI provider (openai | azure | anthropic | gemini | ollama | openrouter) + AI_PROVIDER: openai + OPENAI_MODEL: gpt-4o-mini + + # Azure Document Intelligence (required when OCR_PROVIDERS includes "azure") + AZURE_REGION: eastus + AZURE_ENDPOINT: "" # e.g. https://my-resource.cognitiveservices.azure.com/ + + # Gotenberg PDF conversion service + GOTENBERG_URL: http://{{ include "docuelevate.fullname" . }}-gotenberg:3000 + + # Full-text search — uses the in-cluster Meilisearch service by default + MEILISEARCH_URL: http://{{ include "docuelevate.fullname" . }}-meilisearch:7700 + MEILISEARCH_INDEX_NAME: documents + ENABLE_SEARCH: "true" + + # Authentication + AUTH_ENABLED: "true" + ADMIN_USERNAME: admin + + # Feature flags + ENABLE_DEDUPLICATION: "true" + ENABLE_TEXT_QUALITY_CHECK: "true" + ALLOW_FILE_DELETE: "true" + + # Logging / misc + DEBUG: "false" + EXTERNAL_HOSTNAME: localhost # set to your public hostname / Ingress host + +# --------------------------------------------------------------------------- +# Secrets — values are stored in a Kubernetes Secret and injected as env vars. +# In production, use an external secret manager (Vault, ESO, Sealed Secrets) +# and leave these blank, then mount the Secret yourself. +# --------------------------------------------------------------------------- +secrets: + DATABASE_URL: "" # e.g. postgresql://user:pass@postgres:5432/docuelevate + REDIS_URL: "" # leave blank to use bundled Redis + SESSION_SECRET: "" # min 32-char random string — generate with: openssl rand -hex 32 + OPENAI_API_KEY: "" + AZURE_AI_KEY: "" + MEILISEARCH_API_KEY: "" # leave blank for unauthenticated (dev) Meilisearch + + # Storage provider secrets (only the ones you use) + DROPBOX_APP_KEY: "" + DROPBOX_APP_SECRET: "" + DROPBOX_REFRESH_TOKEN: "" + GOOGLE_DRIVE_CREDENTIALS_JSON: "" + ONEDRIVE_CLIENT_ID: "" + ONEDRIVE_CLIENT_SECRET: "" + ONEDRIVE_REFRESH_TOKEN: "" + AWS_ACCESS_KEY_ID: "" + AWS_SECRET_ACCESS_KEY: "" + + # OAuth / Authentik + AUTHENTIK_CLIENT_ID: "" + AUTHENTIK_CLIENT_SECRET: "" + AUTHENTIK_CONFIG_URL: "" + +# --------------------------------------------------------------------------- +# External services +# Set externalRedis.url (and disable bundled redis) when you have your own. +# --------------------------------------------------------------------------- +externalRedis: + # When non-empty this value is injected as REDIS_URL, overriding secrets.REDIS_URL + # and the auto-generated bundled-Redis URL. + url: "" + +# --------------------------------------------------------------------------- +# API deployment +# --------------------------------------------------------------------------- +api: + replicaCount: 2 + + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi + + # Horizontal Pod Autoscaler + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 8 + targetCPUUtilizationPercentage: 70 + + service: + type: ClusterIP + port: 8000 + + # Liveness / readiness probes + livenessProbe: + httpGet: + path: /api/health + port: 8000 + initialDelaySeconds: 30 + periodSeconds: 20 + failureThreshold: 3 + + readinessProbe: + httpGet: + path: /api/health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 10 + failureThreshold: 3 + + podAnnotations: {} + nodeSelector: {} + tolerations: [] + affinity: {} + + podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false # app writes to /workdir + capabilities: + drop: ["ALL"] + +# --------------------------------------------------------------------------- +# Celery worker deployment +# --------------------------------------------------------------------------- +worker: + replicaCount: 2 + + resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: "2" + memory: 4Gi + + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 75 + + podAnnotations: {} + nodeSelector: {} + tolerations: [] + affinity: {} + + podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: ["ALL"] + +# --------------------------------------------------------------------------- +# Shared workdir volume (api + worker mount the same PVC) +# --------------------------------------------------------------------------- +workdir: + persistence: + enabled: true + # storageClass: "" # leave blank for cluster default + accessMode: ReadWriteMany # RWX required for multiple pods + size: 20Gi + # existingClaim: "" + +# --------------------------------------------------------------------------- +# Gotenberg (PDF conversion) +# --------------------------------------------------------------------------- +gotenberg: + enabled: true + image: + repository: gotenberg/gotenberg + tag: latest + pullPolicy: IfNotPresent + service: + port: 3000 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + +# --------------------------------------------------------------------------- +# Meilisearch (full-text search) +# --------------------------------------------------------------------------- +meilisearch: + enabled: true + image: + repository: getmeili/meilisearch + tag: latest + pullPolicy: IfNotPresent + service: + port: 7700 + env: + MEILI_NO_ANALYTICS: "true" + # MEILI_MASTER_KEY: "" # set via secrets.MEILISEARCH_API_KEY instead + persistence: + enabled: true + # storageClass: "" + accessMode: ReadWriteOnce + size: 10Gi + # existingClaim: "" + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + +# --------------------------------------------------------------------------- +# Bundled Redis (from Bitnami chart) +# Disable and set externalRedis.url to use your own. +# --------------------------------------------------------------------------- +redis: + enabled: true + architecture: standalone + auth: + enabled: false + master: + persistence: + enabled: true + size: 4Gi + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +# --------------------------------------------------------------------------- +# Ingress +# --------------------------------------------------------------------------- +ingress: + enabled: false + className: "" # e.g. nginx, traefik + annotations: {} + # nginx.ingress.kubernetes.io/proxy-body-size: "1g" + # cert-manager.io/cluster-issuer: letsencrypt-prod + hosts: + - host: docuelevate.example.com + paths: + - path: / + pathType: Prefix + tls: [] + # - secretName: docuelevate-tls + # hosts: + # - docuelevate.example.com + +# --------------------------------------------------------------------------- +# ServiceAccount +# --------------------------------------------------------------------------- +serviceAccount: + create: true + annotations: {} + name: "" + +# --------------------------------------------------------------------------- +# Database migration job +# Runs `alembic upgrade head` before the api/worker start. +# --------------------------------------------------------------------------- +migrations: + enabled: true + # Automatically deleted after successful completion + ttlSecondsAfterFinished: 120