diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..30290266 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,96 @@ +# CI/CD Pipeline for FinMind Deployment +# Builds Docker images and pushes to GHCR on main branch +name: Build & Deploy + +on: + push: + branches: [main] + paths: + - 'packages/backend/**' + - 'app/**' + - 'docker-compose.yml' + - 'deploy/**' + pull_request: + branches: [main] + paths: + - 'packages/backend/**' + - 'app/**' + - 'deploy/**' + +env: + REGISTRY: ghcr.io + BACKEND_IMAGE: ghcr.io/${{ github.repository_owner }}/finmind-backend + FRONTEND_IMAGE: ghcr.io/${{ github.repository_owner }}/finmind-frontend + +jobs: + # ── Validate deployment configs ── + validate: + name: Validate Configs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate JSON files + run: | + for f in deploy/platforms/aws/ecs-fargate/task-definition.json deploy/platforms/vercel/vercel.json deploy/platforms/heroku/app.json; do + echo "Validating $f..." + python3 -m json.tool "$f" > /dev/null + done + + - name: Validate YAML files + run: | + pip install pyyaml + for f in deploy/platforms/render/render.yaml deploy/platforms/gcp/cloudrun.yaml deploy/platforms/digitalocean/app-platform/do-app-spec.yaml deploy/helm/finmind/values.yaml; do + echo "Validating $f..." + python3 -c "import yaml; yaml.safe_load(open('$f'))" + done + + - name: Lint Helm chart + run: | + curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + helm lint deploy/helm/finmind + + # ── Build Docker images ── + build: + name: Build Images + runs-on: ubuntu-latest + needs: validate + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - name: backend + context: packages/backend + dockerfile: packages/backend/Dockerfile + image_env: BACKEND_IMAGE + - name: frontend + context: app + dockerfile: app/Dockerfile + image_env: FRONTEND_IMAGE + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push ${{ matrix.name }} + uses: docker/build-push-action@v5 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + push: ${{ github.event_name == 'push' }} + tags: | + ${{ env[matrix.image_env] }}:latest + ${{ env[matrix.image_env] }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Tiltfile b/Tiltfile new file mode 100644 index 00000000..2943a3bb --- /dev/null +++ b/Tiltfile @@ -0,0 +1,364 @@ +# FinMind Tiltfile — Local Kubernetes Development Workflow +# Usage: tilt up +# Requirements: Docker, kubectl, a local K8s cluster (minikube, kind, Docker Desktop K8s) + +# ───────────────────────────────────────────────────────────── +# Configuration +# ───────────────────────────────────────────────────────────── +load('ext://namespace', 'namespace_create', 'namespace_inject') +allow_k8s_contexts(['docker-desktop', 'minikube', 'kind-kind', 'kind-finmind']) +default_registry('localhost:5000') + +# Fail fast if user accidentally points at a production cluster +if k8s_context() not in ['docker-desktop', 'minikube', 'kind-kind', 'kind-finmind']: + fail('Refusing to run Tilt against non-local context: ' + k8s_context()) + +# ───────────────────────────────────────────────────────────── +# Namespace +# ───────────────────────────────────────────────────────────── +namespace_create('finmind') + +# ───────────────────────────────────────────────────────────── +# Docker Builds with Live Update +# ───────────────────────────────────────────────────────────── + +# Backend: Python Flask + Gunicorn +docker_build( + 'finmind-backend', + context='./packages/backend', + dockerfile='./packages/backend/Dockerfile', + live_update=[ + # Sync Python source files for hot-reload (gunicorn --reload) + sync('./packages/backend/app', '/app/app'), + sync('./packages/backend/wsgi.py', '/app/wsgi.py'), + # Restart gunicorn if requirements change + run('pip install -r /app/requirements.txt', + trigger=['./packages/backend/requirements.txt']), + ], +) + +# Frontend: React + Vite (Nginx for production, dev server for Tilt) +docker_build( + 'finmind-frontend-dev', + context='./app', + dockerfile_contents=''' +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci || npm install +COPY . . +EXPOSE 5173 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] +''', + live_update=[ + # Sync source files — Vite HMR handles the rest + sync('./app/src', '/app/src'), + sync('./app/public', '/app/public'), + sync('./app/index.html', '/app/index.html'), + # Reinstall deps if package.json changes + run('cd /app && npm install', + trigger=['./app/package.json', './app/package-lock.json']), + ], +) + +# ───────────────────────────────────────────────────────────── +# Kubernetes Resources — Inline YAML for Dev +# ───────────────────────────────────────────────────────────── + +# Secrets (dev-only, not for production) +k8s_yaml(blob(''' +apiVersion: v1 +kind: Secret +metadata: + name: finmind-secrets + namespace: finmind +type: Opaque +stringData: + POSTGRES_USER: finmind + POSTGRES_PASSWORD: finmind-dev + POSTGRES_DB: finmind + JWT_SECRET: dev-jwt-secret-not-for-production + GRAFANA_ADMIN_USER: admin + GRAFANA_ADMIN_PASSWORD: admin + GEMINI_API_KEY: "" +''')) + +# ConfigMap +k8s_yaml(blob(''' +apiVersion: v1 +kind: ConfigMap +metadata: + name: finmind-config + namespace: finmind +data: + LOG_LEVEL: DEBUG + GEMINI_MODEL: gemini-1.5-flash + REDIS_URL: "redis://redis:6379/0" + VITE_API_URL: "http://localhost:8000" +''')) + +# PostgreSQL +k8s_yaml(blob(''' +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + namespace: finmind +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:16 + ports: + - containerPort: 5432 + envFrom: + - secretRef: + name: finmind-secrets + readinessProbe: + exec: + command: ["pg_isready", "-U", "finmind"] + initialDelaySeconds: 5 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: finmind +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 +''')) + +# Redis +k8s_yaml(blob(''' +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: finmind +spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7 + ports: + - containerPort: 6379 + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 3 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: finmind +spec: + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 +''')) + +# Backend +k8s_yaml(blob(''' +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend + namespace: finmind +spec: + replicas: 1 + selector: + matchLabels: + app: backend + template: + metadata: + labels: + app: backend + spec: + initContainers: + - name: wait-for-postgres + image: busybox:1.36 + command: ["sh", "-c", "until nc -z postgres 5432; do sleep 2; done"] + - name: init-db + image: finmind-backend + command: ["python", "-m", "flask", "--app", "wsgi:app", "init-db"] + env: + - name: POSTGRES_USER + valueFrom: {secretKeyRef: {name: finmind-secrets, key: POSTGRES_USER}} + - name: POSTGRES_PASSWORD + valueFrom: {secretKeyRef: {name: finmind-secrets, key: POSTGRES_PASSWORD}} + - name: POSTGRES_DB + valueFrom: {secretKeyRef: {name: finmind-secrets, key: POSTGRES_DB}} + - name: JWT_SECRET + valueFrom: {secretKeyRef: {name: finmind-secrets, key: JWT_SECRET}} + - name: GEMINI_API_KEY + valueFrom: {secretKeyRef: {name: finmind-secrets, key: GEMINI_API_KEY}} + - name: DATABASE_URL + value: "postgresql+psycopg2://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@postgres:5432/$(POSTGRES_DB)" + - name: REDIS_URL + valueFrom: {configMapKeyRef: {name: finmind-config, key: REDIS_URL}} + - name: GEMINI_MODEL + valueFrom: {configMapKeyRef: {name: finmind-config, key: GEMINI_MODEL}} + - name: LOG_LEVEL + valueFrom: {configMapKeyRef: {name: finmind-config, key: LOG_LEVEL}} + containers: + - name: backend + image: finmind-backend + ports: + - containerPort: 8000 + env: + - name: POSTGRES_USER + valueFrom: {secretKeyRef: {name: finmind-secrets, key: POSTGRES_USER}} + - name: POSTGRES_PASSWORD + valueFrom: {secretKeyRef: {name: finmind-secrets, key: POSTGRES_PASSWORD}} + - name: POSTGRES_DB + valueFrom: {secretKeyRef: {name: finmind-secrets, key: POSTGRES_DB}} + - name: JWT_SECRET + valueFrom: {secretKeyRef: {name: finmind-secrets, key: JWT_SECRET}} + - name: GEMINI_API_KEY + valueFrom: {secretKeyRef: {name: finmind-secrets, key: GEMINI_API_KEY}} + - name: DATABASE_URL + value: "postgresql+psycopg2://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@postgres:5432/$(POSTGRES_DB)" + - name: REDIS_URL + valueFrom: {configMapKeyRef: {name: finmind-config, key: REDIS_URL}} + - name: GEMINI_MODEL + valueFrom: {configMapKeyRef: {name: finmind-config, key: GEMINI_MODEL}} + - name: LOG_LEVEL + valueFrom: {configMapKeyRef: {name: finmind-config, key: LOG_LEVEL}} + command: + - sh + - -c + - | + export PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus_multiproc + rm -rf $PROMETHEUS_MULTIPROC_DIR && mkdir -p $PROMETHEUS_MULTIPROC_DIR + exec gunicorn --reload --workers=2 --threads=4 --bind 0.0.0.0:8000 wsgi:app + readinessProbe: + httpGet: {path: /health, port: 8000} + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: backend + namespace: finmind +spec: + selector: + app: backend + ports: + - port: 8000 + targetPort: 8000 +''')) + +# Frontend (dev mode with Vite HMR) +k8s_yaml(blob(''' +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: finmind +spec: + replicas: 1 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + containers: + - name: frontend + image: finmind-frontend-dev + ports: + - containerPort: 5173 + env: + - name: VITE_API_URL + value: "http://localhost:8000" + readinessProbe: + httpGet: {path: /, port: 5173} + initialDelaySeconds: 15 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend + namespace: finmind +spec: + selector: + app: frontend + ports: + - port: 5173 + targetPort: 5173 +''')) + +# ───────────────────────────────────────────────────────────── +# Resource Configuration +# ───────────────────────────────────────────────────────────── + +# Dependency ordering: postgres -> redis -> backend -> frontend +k8s_resource('postgres', labels=['data']) +k8s_resource('redis', labels=['data']) +k8s_resource('backend', + labels=['app'], + resource_deps=['postgres', 'redis'], + port_forwards=['8000:8000'], +) +k8s_resource('frontend', + labels=['app'], + resource_deps=['backend'], + port_forwards=['5173:5173'], +) + +# ───────────────────────────────────────────────────────────── +# Manual Trigger Buttons +# ───────────────────────────────────────────────────────────── +local_resource( + 'run-backend-tests', + cmd='cd packages/backend && python -m pytest tests/ -v --tb=short 2>&1 || true', + labels=['dev'], + auto_init=False, + trigger_mode=TRIGGER_MODE_MANUAL, +) + +local_resource( + 'run-frontend-tests', + cmd='cd app && npm test -- --watchAll=false 2>&1 || true', + labels=['dev'], + auto_init=False, + trigger_mode=TRIGGER_MODE_MANUAL, +) + +local_resource( + 'smoke-test', + cmd='deploy/scripts/smoke-test.sh http://localhost:8000 http://localhost:5173 2>&1 || true', + labels=['dev'], + auto_init=False, + trigger_mode=TRIGGER_MODE_MANUAL, + resource_deps=['backend', 'frontend'], +) diff --git a/deploy/DEPLOY-GUIDE.md b/deploy/DEPLOY-GUIDE.md new file mode 100644 index 00000000..d2c51a3a --- /dev/null +++ b/deploy/DEPLOY-GUIDE.md @@ -0,0 +1,429 @@ +# FinMind — Universal Deployment Guide + +Production-grade deployment guide covering Docker, Kubernetes (Helm), Tilt, and 12+ cloud platforms. + +## Quick Start + +```bash +# One command — pick your platform: +./deploy/scripts/deploy.sh + +# Examples: +./deploy/scripts/deploy.sh docker-compose # Local development +./deploy/scripts/deploy.sh kubernetes # Production K8s via Helm +./deploy/scripts/deploy.sh tilt # Local K8s with hot-reload +./deploy/scripts/deploy.sh flyio # Fly.io +./deploy/scripts/deploy.sh gcp-cloudrun # GCP Cloud Run +``` + +## Architecture + +``` + ┌──────────────┐ + │ Ingress │ + │ (TLS/HTTPS) │ + └──────┬───────┘ + │ + ┌─────────────┼─────────────┐ + │ │ │ + ┌─────┴─────┐ ┌────┴────┐ ┌─────┴─────┐ + │ Frontend │ │ Nginx │ │ Grafana │ + │ (React) │ │ (Proxy) │ │ (Monitor) │ + │ Port 80 │ │ Port 80 │ │ Port 3000 │ + └───────────┘ └────┬────┘ └───────────┘ + │ + ┌─────┴─────┐ + │ Backend │ + │ (Flask) │ + │ Port 8000 │ + └─────┬─────┘ + │ + ┌────────────┼────────────┐ + │ │ + ┌─────┴─────┐ ┌──────┴──────┐ + │ PostgreSQL │ │ Redis │ + │ Port 5432 │ │ Port 6379 │ + └────────────┘ └─────────────┘ +``` + +## Table of Contents + +- [Docker Compose (Local Development)](#docker-compose-local-development) +- [Kubernetes via Helm](#kubernetes-via-helm) +- [Tilt (Local K8s Dev)](#tilt-local-k8s-dev) +- [Railway](#railway) +- [Heroku](#heroku) +- [Render](#render) +- [Fly.io](#flyio) +- [DigitalOcean App Platform](#digitalocean-app-platform) +- [DigitalOcean Droplet](#digitalocean-droplet) +- [AWS ECS Fargate](#aws-ecs-fargate) +- [AWS App Runner](#aws-app-runner) +- [GCP Cloud Run](#gcp-cloud-run) +- [Azure Container Apps](#azure-container-apps) +- [Netlify (Frontend)](#netlify-frontend) +- [Vercel (Frontend)](#vercel-frontend) +- [Smoke Tests](#smoke-tests) +- [Troubleshooting](#troubleshooting) + +--- + +## Docker Compose (Local Development) + +The simplest way to run FinMind locally. + +```bash +# 1. Copy environment file +cp .env.example .env + +# 2. Start all services +docker compose up -d --build + +# 3. Verify +curl http://localhost:8000/health +open http://localhost:5173 +``` + +**Services:** +| Service | URL | +|---------|-----| +| Frontend | http://localhost:5173 | +| Backend API | http://localhost:8000 | +| Grafana | http://localhost:3000 | +| Prometheus | http://localhost:9090 | + +--- + +## Kubernetes via Helm + +Production-grade deployment with autoscaling, TLS, network policies, and observability. + +### Prerequisites +- `kubectl` connected to a cluster +- `helm` v3+ + +### Deploy + +```bash +# Default installation +helm upgrade --install finmind deploy/helm/finmind \ + --namespace finmind --create-namespace --wait + +# Production with custom values +helm upgrade --install finmind deploy/helm/finmind \ + --namespace finmind --create-namespace \ + --set secrets.jwtSecret=$(openssl rand -hex 32) \ + --set secrets.postgresPassword=$(openssl rand -hex 16) \ + --set ingress.hosts[0].host=finmind.yourdomain.com \ + --set ingress.tls[0].hosts[0]=finmind.yourdomain.com \ + --wait --timeout 5m +``` + +### What's Included +- **HorizontalPodAutoscaler**: Backend 2-10 replicas, Frontend 2-6 replicas +- **PodDisruptionBudget**: Guarantees availability during rolling updates +- **NetworkPolicy**: Zero-trust — only authorized pods can communicate +- **Ingress with TLS**: cert-manager integration for automatic HTTPS +- **Health probes**: Readiness + liveness on all services +- **Init containers**: Wait for dependencies, auto-run DB migrations +- **Prometheus annotations**: Backend pods are scrape-ready +- **ConfigMap checksums**: Pods restart automatically when config changes + +### Helm Values Override + +```bash +# See all configurable values +helm show values deploy/helm/finmind + +# Example: production-values.yaml +cat < production-values.yaml +secrets: + postgresPassword: "super-secure-password" + jwtSecret: "long-random-secret" +backend: + autoscaling: + minReplicas: 3 + maxReplicas: 20 +ingress: + hosts: + - host: finmind.company.com + paths: + - path: / + pathType: Prefix + service: frontend + port: 80 +EOF + +helm upgrade --install finmind deploy/helm/finmind -f production-values.yaml +``` + +--- + +## Tilt (Local K8s Dev) + +Best for development — live-reload for both backend (Python) and frontend (React/Vite). + +### Prerequisites +- Docker Desktop with Kubernetes enabled, or minikube/kind +- [Tilt](https://docs.tilt.dev/install.html) + +### Run + +```bash +tilt up +``` + +**Features:** +- Backend: Python file sync + gunicorn `--reload` (no rebuild needed) +- Frontend: Vite HMR via file sync (instant updates) +- Dependency ordering: postgres -> redis -> backend -> frontend +- Port forwarding: Backend on :8000, Frontend on :5173 +- Manual trigger buttons: Run tests, smoke tests from Tilt UI +- Resource grouping: app, data, dev categories + +### Tilt UI + +Open http://localhost:10350 to see: +- Build/deploy status for all services +- Live logs +- Manual action buttons (run tests, smoke tests) + +--- + +## Railway + +```bash +./deploy/scripts/deploy.sh railway +``` + +Or manually: +1. Fork this repo +2. Go to [railway.app](https://railway.app) +3. New Project -> Deploy from GitHub +4. Add PostgreSQL and Redis plugins +5. Railway auto-detects `deploy/platforms/railway/railway.toml` + +--- + +## Heroku + +```bash +./deploy/scripts/deploy.sh heroku +``` + +Or one-click deploy (after pushing `app.json` to repo root): +[![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) + +Config: `deploy/platforms/heroku/app.json`, `deploy/platforms/heroku/heroku.yml` + +--- + +## Render + +```bash +./deploy/scripts/deploy.sh render +``` + +Or use Render Blueprint: +1. Go to [render.com/blueprints](https://dashboard.render.com/blueprints) +2. Connect this repo +3. Render auto-provisions backend, frontend, PostgreSQL, and Redis + +Blueprint: `deploy/platforms/render/render.yaml` + +--- + +## Fly.io + +```bash +./deploy/scripts/deploy.sh flyio +``` + +Deploys both backend and frontend as separate Fly apps with health checks and auto-scaling. + +Config: `deploy/platforms/flyio/backend/fly.toml`, `deploy/platforms/flyio/frontend/fly.toml` + +--- + +## DigitalOcean App Platform + +```bash +./deploy/scripts/deploy.sh digitalocean +``` + +Or via CLI: +```bash +doctl apps create --spec deploy/platforms/digitalocean/app-platform/do-app-spec.yaml +``` + +Includes managed PostgreSQL 16 and Redis 7. + +--- + +## DigitalOcean Droplet + +One-line install on a fresh Ubuntu droplet: + +```bash +curl -sSL https://raw.githubusercontent.com/rohitdash08/FinMind/main/deploy/platforms/digitalocean/droplet/setup.sh | sudo bash +``` + +Installs Docker, clones the repo, generates secure secrets, and starts everything. + +--- + +## AWS ECS Fargate + +```bash +./deploy/scripts/deploy.sh aws-ecs +``` + +Prerequisites: AWS CLI configured, ECR repository, ECS cluster. + +The script: +1. Builds and pushes to ECR +2. Registers task definition with SSM Parameter Store secrets +3. Deploys to ECS Fargate + +Config: `deploy/platforms/aws/ecs-fargate/task-definition.json` + +--- + +## AWS App Runner + +```bash +./deploy/scripts/deploy.sh aws-apprunner +``` + +Simplest AWS option — auto-scaling with zero infrastructure management. + +Config: `deploy/platforms/aws/app-runner/apprunner.yaml` + +--- + +## GCP Cloud Run + +```bash +export GCP_PROJECT_ID=your-project-id +./deploy/scripts/deploy.sh gcp-cloudrun +``` + +The script: +1. Enables required GCP APIs +2. Builds with Cloud Build +3. Creates Cloud SQL PostgreSQL + Memorystore Redis +4. Deploys to Cloud Run with Secret Manager integration + +Config: `deploy/platforms/gcp/cloudrun.yaml` + +--- + +## Azure Container Apps + +```bash +./deploy/scripts/deploy.sh azure +``` + +The script: +1. Creates resource group + Azure Container Registry +2. Builds and pushes image via ACR +3. Creates Container Apps environment +4. Provisions Azure Database for PostgreSQL + Azure Cache for Redis +5. Deploys with auto-scaling (1-10 replicas) + +Config: `deploy/platforms/azure/container-app.yaml` + +--- + +## Netlify (Frontend) + +```bash +./deploy/scripts/deploy.sh netlify +``` + +Features: SPA fallback, API proxy, security headers, static asset caching. + +Config: `deploy/platforms/netlify/netlify.toml` + +--- + +## Vercel (Frontend) + +```bash +./deploy/scripts/deploy.sh vercel +``` + +Features: SPA rewrites, security headers, asset caching. + +Config: `deploy/platforms/vercel/vercel.json` + +--- + +## Smoke Tests + +Validate any running deployment against the acceptance criteria: + +```bash +# Test local deployment +./deploy/scripts/smoke-test.sh http://localhost:8000 http://localhost:5173 + +# Test remote deployment +./deploy/scripts/smoke-test.sh https://api.finmind.example.com https://finmind.example.com +``` + +**Checks:** +1. Frontend reachable (HTTP 200) +2. Backend health endpoint responsive +3. Database connectivity (via /health) +4. Auth endpoints responsive (register, login) +5. Core module endpoints exist (expenses, bills, reminders, dashboard, insights) + +--- + +## Troubleshooting + +### Docker Compose: Backend won't start +```bash +# Check logs +docker compose logs backend + +# Verify postgres is healthy +docker compose exec postgres pg_isready -U finmind + +# Re-run DB init +docker compose exec backend python -m flask --app wsgi:app init-db +``` + +### Kubernetes: Pods in CrashLoopBackOff +```bash +# Check pod logs +kubectl logs -n finmind deployment/backend --previous + +# Check events +kubectl get events -n finmind --sort-by=.metadata.creationTimestamp + +# Verify secrets +kubectl get secret finmind-secrets -n finmind -o yaml +``` + +### Helm: Upgrade fails +```bash +# Check release status +helm status finmind -n finmind + +# Rollback +helm rollback finmind -n finmind + +# Full reinstall +helm uninstall finmind -n finmind +helm install finmind deploy/helm/finmind -n finmind --create-namespace +``` + +### Tilt: Images not building +```bash +# Verify local K8s context +kubectl config current-context + +# Restart Tilt +tilt down && tilt up +``` diff --git a/deploy/helm/finmind/Chart.yaml b/deploy/helm/finmind/Chart.yaml new file mode 100644 index 00000000..f39a59f6 --- /dev/null +++ b/deploy/helm/finmind/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: finmind +description: A Helm chart for FinMind - Personal Finance Management Platform +type: application +version: 1.0.0 +appVersion: "1.0.0" +keywords: + - finmind + - finance + - kubernetes + - helm +maintainers: + - name: FinMind Team +home: https://github.com/rohitdash08/FinMind +sources: + - https://github.com/rohitdash08/FinMind diff --git a/deploy/helm/finmind/templates/_helpers.tpl b/deploy/helm/finmind/templates/_helpers.tpl new file mode 100644 index 00000000..ec09e721 --- /dev/null +++ b/deploy/helm/finmind/templates/_helpers.tpl @@ -0,0 +1,125 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "finmind.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "finmind.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 name and version as used by the chart label. +*/}} +{{- define "finmind.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "finmind.labels" -}} +helm.sh/chart: {{ include "finmind.chart" . }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: finmind +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} + +{{/* +Selector labels for a specific component +*/}} +{{- define "finmind.selectorLabels" -}} +app.kubernetes.io/name: {{ include "finmind.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Backend labels +*/}} +{{- define "finmind.backend.labels" -}} +{{ include "finmind.labels" . }} +app.kubernetes.io/component: backend +app: backend +{{- end }} + +{{/* +Frontend labels +*/}} +{{- define "finmind.frontend.labels" -}} +{{ include "finmind.labels" . }} +app.kubernetes.io/component: frontend +app: frontend +{{- end }} + +{{/* +Postgres labels +*/}} +{{- define "finmind.postgres.labels" -}} +{{ include "finmind.labels" . }} +app.kubernetes.io/component: database +app: postgres +{{- end }} + +{{/* +Redis labels +*/}} +{{- define "finmind.redis.labels" -}} +{{ include "finmind.labels" . }} +app.kubernetes.io/component: cache +app: redis +{{- end }} + +{{/* +Nginx labels +*/}} +{{- define "finmind.nginx.labels" -}} +{{ include "finmind.labels" . }} +app.kubernetes.io/component: proxy +app: nginx +{{- end }} + +{{/* +Namespace +*/}} +{{- define "finmind.namespace" -}} +{{- default "finmind" .Values.global.namespace }} +{{- end }} + +{{/* +Database URL construction +*/}} +{{- define "finmind.databaseUrl" -}} +postgresql+psycopg2://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@postgres:5432/$(POSTGRES_DB) +{{- end }} + +{{/* +ConfigMap checksum annotation — forces pod restart when config changes +*/}} +{{- define "finmind.configChecksum" -}} +checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} +checksum/secret: {{ include (print $.Template.BasePath "/secrets.yaml") . | sha256sum }} +{{- end }} + +{{/* +Service Account name +*/}} +{{- define "finmind.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "finmind.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/deploy/helm/finmind/templates/backend.yaml b/deploy/helm/finmind/templates/backend.yaml new file mode 100644 index 00000000..4f994d52 --- /dev/null +++ b/deploy/helm/finmind/templates/backend.yaml @@ -0,0 +1,208 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.backend.labels" . | nindent 4 }} +spec: + {{- if not .Values.backend.autoscaling.enabled }} + replicas: {{ .Values.backend.replicas }} + {{- end }} + selector: + matchLabels: + app: backend + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + {{- include "finmind.backend.labels" . | nindent 8 }} + annotations: + {{- include "finmind.configChecksum" . | nindent 8 }} + {{- if .Values.backend.prometheus.enabled }} + prometheus.io/scrape: "true" + prometheus.io/path: {{ .Values.backend.prometheus.path | quote }} + prometheus.io/port: {{ .Values.backend.prometheus.port | quote }} + {{- end }} + spec: + serviceAccountName: {{ include "finmind.serviceAccountName" . }} + terminationGracePeriodSeconds: 30 + initContainers: + - name: wait-for-postgres + image: busybox:1.36 + command: + - sh + - -c + - | + echo "Waiting for postgres..." + until nc -z postgres {{ .Values.postgres.service.port }}; do + sleep 2 + done + echo "Postgres is ready!" + - name: wait-for-redis + image: busybox:1.36 + command: + - sh + - -c + - | + echo "Waiting for redis..." + until nc -z redis {{ .Values.redis.service.port }}; do + sleep 2 + done + echo "Redis is ready!" + - name: init-db + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + command: + - python + - -m + - flask + - --app + - wsgi:app + - init-db + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: finmind-secrets + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: finmind-secrets + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + valueFrom: + secretKeyRef: + name: finmind-secrets + key: POSTGRES_DB + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: finmind-secrets + key: JWT_SECRET + - name: GEMINI_API_KEY + valueFrom: + secretKeyRef: + name: finmind-secrets + key: GEMINI_API_KEY + - name: DATABASE_URL + value: {{ include "finmind.databaseUrl" . | quote }} + - name: REDIS_URL + valueFrom: + configMapKeyRef: + name: finmind-config + key: REDIS_URL + - name: GEMINI_MODEL + valueFrom: + configMapKeyRef: + name: finmind-config + key: GEMINI_MODEL + - name: LOG_LEVEL + valueFrom: + configMapKeyRef: + name: finmind-config + key: LOG_LEVEL + containers: + - name: backend + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + ports: + - containerPort: 8000 + protocol: TCP + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: finmind-secrets + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: finmind-secrets + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + valueFrom: + secretKeyRef: + name: finmind-secrets + key: POSTGRES_DB + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: finmind-secrets + key: JWT_SECRET + - name: GEMINI_API_KEY + valueFrom: + secretKeyRef: + name: finmind-secrets + key: GEMINI_API_KEY + - name: DATABASE_URL + value: {{ include "finmind.databaseUrl" . | quote }} + - name: REDIS_URL + valueFrom: + configMapKeyRef: + name: finmind-config + key: REDIS_URL + - name: GEMINI_MODEL + valueFrom: + configMapKeyRef: + name: finmind-config + key: GEMINI_MODEL + - name: LOG_LEVEL + valueFrom: + configMapKeyRef: + name: finmind-config + key: LOG_LEVEL + command: + - sh + - -c + - | + export PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus_multiproc + rm -rf $PROMETHEUS_MULTIPROC_DIR + mkdir -p $PROMETHEUS_MULTIPROC_DIR + exec gunicorn --workers=2 --threads=4 --bind 0.0.0.0:8000 \ + --graceful-timeout=30 --timeout=120 \ + --access-logfile=- --error-logfile=- \ + wsgi:app + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} + readinessProbe: + httpGet: + path: {{ .Values.backend.healthCheck.path }} + port: 8000 + initialDelaySeconds: {{ .Values.backend.healthCheck.initialDelaySeconds }} + periodSeconds: {{ .Values.backend.healthCheck.periodSeconds }} + timeoutSeconds: {{ .Values.backend.healthCheck.timeoutSeconds }} + failureThreshold: {{ .Values.backend.healthCheck.failureThreshold }} + successThreshold: {{ .Values.backend.healthCheck.successThreshold }} + livenessProbe: + httpGet: + path: {{ .Values.backend.healthCheck.path }} + port: 8000 + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: {{ .Values.backend.healthCheck.timeoutSeconds }} + failureThreshold: 5 + lifecycle: + preStop: + exec: + command: ["sh", "-c", "sleep 5"] +--- +apiVersion: v1 +kind: Service +metadata: + name: backend + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.backend.labels" . | nindent 4 }} +spec: + selector: + app: backend + ports: + - port: {{ .Values.backend.service.port }} + targetPort: 8000 + protocol: TCP diff --git a/deploy/helm/finmind/templates/configmap.yaml b/deploy/helm/finmind/templates/configmap.yaml new file mode 100644 index 00000000..3aa4b090 --- /dev/null +++ b/deploy/helm/finmind/templates/configmap.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: finmind-config + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.labels" . | nindent 4 }} +data: + LOG_LEVEL: {{ .Values.backend.env.LOG_LEVEL | quote }} + GEMINI_MODEL: {{ .Values.backend.env.GEMINI_MODEL | quote }} + REDIS_URL: "redis://redis:6379/0" + VITE_API_URL: "http://backend:8000" diff --git a/deploy/helm/finmind/templates/frontend.yaml b/deploy/helm/finmind/templates/frontend.yaml new file mode 100644 index 00000000..a5e1ce03 --- /dev/null +++ b/deploy/helm/finmind/templates/frontend.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.frontend.labels" . | nindent 4 }} +spec: + {{- if not .Values.frontend.autoscaling.enabled }} + replicas: {{ .Values.frontend.replicas }} + {{- end }} + selector: + matchLabels: + app: frontend + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + {{- include "finmind.frontend.labels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "finmind.serviceAccountName" . }} + containers: + - name: frontend + image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + ports: + - containerPort: 80 + protocol: TCP + resources: + {{- toYaml .Values.frontend.resources | nindent 12 }} + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 10 + periodSeconds: 20 +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.frontend.labels" . | nindent 4 }} +spec: + selector: + app: frontend + ports: + - port: {{ .Values.frontend.service.port }} + targetPort: 80 + protocol: TCP diff --git a/deploy/helm/finmind/templates/hpa.yaml b/deploy/helm/finmind/templates/hpa.yaml new file mode 100644 index 00000000..d27c3989 --- /dev/null +++ b/deploy/helm/finmind/templates/hpa.yaml @@ -0,0 +1,57 @@ +{{- if .Values.backend.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: backend-hpa + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.backend.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: backend + minReplicas: {{ .Values.backend.autoscaling.minReplicas }} + maxReplicas: {{ .Values.backend.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.backend.autoscaling.targetCPUUtilizationPercentage }} + {{- if .Values.backend.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.backend.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} + behavior: + {{- toYaml .Values.backend.autoscaling.behavior | nindent 4 }} +{{- end }} +--- +{{- if .Values.frontend.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: frontend-hpa + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.frontend.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: frontend + minReplicas: {{ .Values.frontend.autoscaling.minReplicas }} + maxReplicas: {{ .Values.frontend.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.frontend.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} diff --git a/deploy/helm/finmind/templates/ingress.yaml b/deploy/helm/finmind/templates/ingress.yaml new file mode 100644 index 00000000..197ee01b --- /dev/null +++ b/deploy/helm/finmind/templates/ingress.yaml @@ -0,0 +1,42 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: finmind-ingress + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.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: + {{- range .Values.ingress.tls }} + - secretName: {{ .secretName }} + hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ .service }} + port: + number: {{ .port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/helm/finmind/templates/namespace.yaml b/deploy/helm/finmind/templates/namespace.yaml new file mode 100644 index 00000000..2953f0a5 --- /dev/null +++ b/deploy/helm/finmind/templates/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.labels" . | nindent 4 }} diff --git a/deploy/helm/finmind/templates/networkpolicy.yaml b/deploy/helm/finmind/templates/networkpolicy.yaml new file mode 100644 index 00000000..7c7a1b22 --- /dev/null +++ b/deploy/helm/finmind/templates/networkpolicy.yaml @@ -0,0 +1,149 @@ +{{- if .Values.networkPolicy.enabled }} +# Zero-trust: PostgreSQL only accepts connections from backend +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: postgres-network-policy + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app: postgres + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app: backend + - podSelector: + matchLabels: + app: postgres-exporter + ports: + - protocol: TCP + port: 5432 +--- +# Zero-trust: Redis only accepts connections from backend +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: redis-network-policy + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app: redis + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app: backend + - podSelector: + matchLabels: + app: redis-exporter + ports: + - protocol: TCP + port: 6379 +--- +# Backend accepts from nginx and prometheus only +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: backend-network-policy + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app: backend + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchLabels: + app: nginx + - podSelector: + matchLabels: + app: prometheus + ports: + - protocol: TCP + port: 8000 + egress: + - to: + - podSelector: + matchLabels: + app: postgres + ports: + - protocol: TCP + port: 5432 + - to: + - podSelector: + matchLabels: + app: redis + ports: + - protocol: TCP + port: 6379 + # Allow DNS resolution + - to: [] + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # Allow external HTTPS (APIs: Gemini, OpenAI, Twilio, SMTP) + - to: [] + ports: + - protocol: TCP + port: 443 + - protocol: TCP + port: 587 +--- +# Nginx accepts from ingress controller +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: nginx-network-policy + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app: nginx + policyTypes: + - Ingress + ingress: + - from: [] + ports: + - protocol: TCP + port: 80 +--- +# Frontend accepts from ingress controller +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: frontend-network-policy + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app: frontend + policyTypes: + - Ingress + ingress: + - from: [] + ports: + - protocol: TCP + port: 80 +{{- end }} diff --git a/deploy/helm/finmind/templates/nginx.yaml b/deploy/helm/finmind/templates/nginx.yaml new file mode 100644 index 00000000..4c780afd --- /dev/null +++ b/deploy/helm/finmind/templates/nginx.yaml @@ -0,0 +1,111 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: nginx-config + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.nginx.labels" . | nindent 4 }} +data: + default.conf: | + upstream backend { + server backend:{{ .Values.backend.service.port }}; + } + + server { + listen 80; + server_name _; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # API proxy + location / { + proxy_pass http://backend; + proxy_http_version 1.1; + 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; + proxy_connect_timeout 30s; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + + # Prometheus metrics passthrough + location /metrics { + proxy_pass http://backend; + proxy_http_version 1.1; + } + + # Nginx status for monitoring + location /nginx_status { + stub_status; + allow all; + } + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.nginx.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.nginx.replicas }} + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + {{- include "finmind.nginx.labels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "finmind.serviceAccountName" . }} + containers: + - name: nginx + image: "{{ .Values.nginx.image.repository }}:{{ .Values.nginx.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + ports: + - containerPort: 80 + protocol: TCP + resources: + {{- toYaml .Values.nginx.resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /etc/nginx/conf.d/default.conf + subPath: default.conf + readinessProbe: + httpGet: + path: /nginx_status + port: 80 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /nginx_status + port: 80 + initialDelaySeconds: 10 + periodSeconds: 20 + volumes: + - name: config + configMap: + name: nginx-config +--- +apiVersion: v1 +kind: Service +metadata: + name: nginx + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.nginx.labels" . | nindent 4 }} +spec: + selector: + app: nginx + ports: + - port: {{ .Values.nginx.service.port }} + targetPort: 80 + protocol: TCP diff --git a/deploy/helm/finmind/templates/pdb.yaml b/deploy/helm/finmind/templates/pdb.yaml new file mode 100644 index 00000000..9191445b --- /dev/null +++ b/deploy/helm/finmind/templates/pdb.yaml @@ -0,0 +1,29 @@ +{{- if .Values.backend.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: backend-pdb + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.backend.labels" . | nindent 4 }} +spec: + minAvailable: {{ .Values.backend.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + app: backend +{{- end }} +--- +{{- if .Values.frontend.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: frontend-pdb + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.frontend.labels" . | nindent 4 }} +spec: + minAvailable: {{ .Values.frontend.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + app: frontend +{{- end }} diff --git a/deploy/helm/finmind/templates/postgres.yaml b/deploy/helm/finmind/templates/postgres.yaml new file mode 100644 index 00000000..4face3de --- /dev/null +++ b/deploy/helm/finmind/templates/postgres.yaml @@ -0,0 +1,92 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-data + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.postgres.labels" . | nindent 4 }} +spec: + accessModes: [ReadWriteOnce] + {{- if .Values.postgres.storage.storageClass }} + storageClassName: {{ .Values.postgres.storage.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.postgres.storage.size }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.postgres.labels" . | nindent 4 }} +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + {{- include "finmind.postgres.labels" . | nindent 8 }} + spec: + containers: + - name: postgres + image: "{{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + ports: + - containerPort: 5432 + protocol: TCP + envFrom: + - secretRef: + name: finmind-secrets + resources: + {{- toYaml .Values.postgres.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + subPath: pgdata + readinessProbe: + exec: + command: + - pg_isready + - -U + - $(POSTGRES_USER) + - -d + - $(POSTGRES_DB) + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + exec: + command: + - pg_isready + - -U + - $(POSTGRES_USER) + - -d + - $(POSTGRES_DB) + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + volumes: + - name: data + persistentVolumeClaim: + claimName: postgres-data +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.postgres.labels" . | nindent 4 }} +spec: + selector: + app: postgres + ports: + - port: {{ .Values.postgres.service.port }} + targetPort: 5432 + protocol: TCP diff --git a/deploy/helm/finmind/templates/redis.yaml b/deploy/helm/finmind/templates/redis.yaml new file mode 100644 index 00000000..5ac4244f --- /dev/null +++ b/deploy/helm/finmind/templates/redis.yaml @@ -0,0 +1,53 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.redis.labels" . | nindent 4 }} +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: redis + template: + metadata: + labels: + {{- include "finmind.redis.labels" . | nindent 8 }} + spec: + containers: + - name: redis + image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + ports: + - containerPort: 6379 + protocol: TCP + resources: + {{- toYaml .Values.redis.resources | nindent 12 }} + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 15 + periodSeconds: 20 +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.redis.labels" . | nindent 4 }} +spec: + selector: + app: redis + ports: + - port: {{ .Values.redis.service.port }} + targetPort: 6379 + protocol: TCP diff --git a/deploy/helm/finmind/templates/secrets.yaml b/deploy/helm/finmind/templates/secrets.yaml new file mode 100644 index 00000000..ff014f86 --- /dev/null +++ b/deploy/helm/finmind/templates/secrets.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Secret +metadata: + name: finmind-secrets + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.labels" . | nindent 4 }} +type: Opaque +stringData: + POSTGRES_USER: {{ .Values.secrets.postgresUser | quote }} + POSTGRES_PASSWORD: {{ .Values.secrets.postgresPassword | quote }} + POSTGRES_DB: {{ .Values.secrets.postgresDb | quote }} + JWT_SECRET: {{ .Values.secrets.jwtSecret | quote }} + GRAFANA_ADMIN_USER: {{ .Values.secrets.grafanaAdminUser | quote }} + GRAFANA_ADMIN_PASSWORD: {{ .Values.secrets.grafanaAdminPassword | quote }} + GEMINI_API_KEY: {{ .Values.secrets.geminiApiKey | quote }} + OPENAI_API_KEY: {{ .Values.secrets.openaiApiKey | quote }} diff --git a/deploy/helm/finmind/templates/serviceaccount.yaml b/deploy/helm/finmind/templates/serviceaccount.yaml new file mode 100644 index 00000000..67946bda --- /dev/null +++ b/deploy/helm/finmind/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "finmind.serviceAccountName" . }} + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/finmind/values.yaml b/deploy/helm/finmind/values.yaml new file mode 100644 index 00000000..4a776a3d --- /dev/null +++ b/deploy/helm/finmind/values.yaml @@ -0,0 +1,201 @@ +# FinMind Helm Chart Values +# Production-grade defaults with sensible overrides + +global: + namespace: finmind + imagePullPolicy: IfNotPresent + +# --- Backend (Flask + Gunicorn) --- +backend: + image: + repository: ghcr.io/rohitdash08/finmind-backend + tag: latest + replicas: 2 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: "1" + memory: 512Mi + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 + targetMemoryUtilizationPercentage: 80 + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Pods + value: 1 + periodSeconds: 60 + scaleUp: + stabilizationWindowSeconds: 30 + policies: + - type: Pods + value: 2 + periodSeconds: 60 + service: + type: ClusterIP + port: 8000 + healthCheck: + path: /health + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 + podDisruptionBudget: + enabled: true + minAvailable: 1 + env: + LOG_LEVEL: INFO + GEMINI_MODEL: gemini-1.5-flash + prometheus: + enabled: true + path: /metrics + port: 8000 + +# --- Frontend (React + Nginx) --- +frontend: + image: + repository: ghcr.io/rohitdash08/finmind-frontend + tag: latest + replicas: 2 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 6 + targetCPUUtilizationPercentage: 75 + service: + type: ClusterIP + port: 80 + podDisruptionBudget: + enabled: true + minAvailable: 1 + +# --- PostgreSQL --- +postgres: + image: + repository: postgres + tag: "16" + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + storage: + size: 10Gi + storageClass: "" + service: + port: 5432 + +# --- Redis --- +redis: + image: + repository: redis + tag: "7" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + service: + port: 6379 + +# --- Nginx Reverse Proxy --- +nginx: + image: + repository: nginx + tag: 1.27-alpine + replicas: 1 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + service: + type: ClusterIP + port: 80 + +# --- Ingress --- +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/rate-limit: "100" + nginx.ingress.kubernetes.io/rate-limit-window: "1m" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/proxy-body-size: "10m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "60" + nginx.ingress.kubernetes.io/proxy-send-timeout: "60" + hosts: + - host: finmind.example.com + paths: + - path: / + pathType: Prefix + service: frontend + port: 80 + - path: /api + pathType: Prefix + service: nginx + port: 80 + - path: /health + pathType: Exact + service: nginx + port: 80 + tls: + - secretName: finmind-tls + hosts: + - finmind.example.com + +# --- Network Policies --- +networkPolicy: + enabled: true + +# --- Secrets (override in production) --- +secrets: + postgresUser: finmind + postgresPassword: change-me-strong + postgresDb: finmind + jwtSecret: change-me-long-random-secret + grafanaAdminUser: finmind_admin + grafanaAdminPassword: change-me-admin-password + geminiApiKey: "" + openaiApiKey: "" + +# --- Security Context --- +securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + +# --- Service Account --- +serviceAccount: + create: true + name: finmind + annotations: {} + +# --- Observability --- +monitoring: + enabled: true + prometheus: + enabled: true + grafana: + enabled: true diff --git a/deploy/platforms/aws/app-runner/apprunner.yaml b/deploy/platforms/aws/app-runner/apprunner.yaml new file mode 100644 index 00000000..eee9f6df --- /dev/null +++ b/deploy/platforms/aws/app-runner/apprunner.yaml @@ -0,0 +1,27 @@ +# AWS App Runner configuration +# Deploy: aws apprunner create-service --cli-input-yaml file://deploy/platforms/aws/app-runner/apprunner.yaml +ServiceName: finmind-backend +SourceConfiguration: + AutoDeploymentsEnabled: true + ImageRepository: + ImageIdentifier: ghcr.io/rohitdash08/finmind-backend:latest + ImageRepositoryType: ECR_PUBLIC + ImageConfiguration: + Port: "8000" + RuntimeEnvironmentVariables: + LOG_LEVEL: INFO + GEMINI_MODEL: gemini-1.5-flash + RuntimeEnvironmentSecrets: + DATABASE_URL: "arn:aws:ssm:us-east-1:ACCOUNT_ID:parameter/finmind/DATABASE_URL" + REDIS_URL: "arn:aws:ssm:us-east-1:ACCOUNT_ID:parameter/finmind/REDIS_URL" + JWT_SECRET: "arn:aws:ssm:us-east-1:ACCOUNT_ID:parameter/finmind/JWT_SECRET" +InstanceConfiguration: + Cpu: "0.5 vCPU" + Memory: "1 GB" +HealthCheckConfiguration: + Protocol: HTTP + Path: /health + Interval: 10 + Timeout: 5 + HealthyThreshold: 1 + UnhealthyThreshold: 3 diff --git a/deploy/platforms/aws/ecs-fargate/deploy.sh b/deploy/platforms/aws/ecs-fargate/deploy.sh new file mode 100755 index 00000000..17204fcf --- /dev/null +++ b/deploy/platforms/aws/ecs-fargate/deploy.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# AWS ECS Fargate Deployment Script +# Prerequisites: aws CLI configured, ECR repository created +set -euo pipefail + +REGION="${AWS_REGION:-us-east-1}" +CLUSTER_NAME="${ECS_CLUSTER:-finmind-cluster}" +SERVICE_NAME="${ECS_SERVICE:-finmind-service}" +ECR_REPO="${ECR_REPOSITORY:-finmind-backend}" +ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +ECR_URI="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/${ECR_REPO}" + +echo "======================================" +echo " FinMind — AWS ECS Fargate Deploy" +echo "======================================" + +# 1. Authenticate to ECR +echo "[1/5] Authenticating with ECR..." +aws ecr get-login-password --region "$REGION" | \ + docker login --username AWS --password-stdin "${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com" + +# 2. Build and push +echo "[2/5] Building backend image..." +docker build -t "$ECR_REPO" -f packages/backend/Dockerfile packages/backend/ +docker tag "$ECR_REPO:latest" "$ECR_URI:latest" +docker tag "$ECR_REPO:latest" "$ECR_URI:$(git rev-parse --short HEAD)" + +echo "[3/5] Pushing to ECR..." +docker push "$ECR_URI:latest" +docker push "$ECR_URI:$(git rev-parse --short HEAD)" + +# 3. Store secrets in SSM Parameter Store (first-time only) +echo "[4/5] Checking SSM parameters..." +for param in DATABASE_URL REDIS_URL JWT_SECRET GEMINI_API_KEY; do + if ! aws ssm get-parameter --name "/finmind/${param}" --region "$REGION" &>/dev/null; then + echo " WARNING: /finmind/${param} not found in SSM. Set it with:" + echo " aws ssm put-parameter --name '/finmind/${param}' --value 'YOUR_VALUE' --type SecureString --region $REGION" + fi +done + +# 4. Update task definition with correct ECR URI and deploy +echo "[5/5] Deploying to ECS..." +TASK_DEF=$(cat deploy/platforms/aws/ecs-fargate/task-definition.json | \ + sed "s|ghcr.io/rohitdash08/finmind-backend:latest|${ECR_URI}:latest|g" | \ + sed "s|arn:aws:ssm:us-east-1::|arn:aws:ssm:${REGION}:${ACCOUNT_ID}|g" | \ + sed "s|arn:aws:iam::|arn:aws:iam::${ACCOUNT_ID}|g") + +TASK_ARN=$(echo "$TASK_DEF" | aws ecs register-task-definition \ + --cli-input-json "file:///dev/stdin" \ + --query 'taskDefinition.taskDefinitionArn' \ + --output text --region "$REGION") + +aws ecs update-service \ + --cluster "$CLUSTER_NAME" \ + --service "$SERVICE_NAME" \ + --task-definition "$TASK_ARN" \ + --force-new-deployment \ + --region "$REGION" > /dev/null + +echo "" +echo "Deployment initiated. Monitor with:" +echo " aws ecs describe-services --cluster $CLUSTER_NAME --services $SERVICE_NAME --region $REGION" diff --git a/deploy/platforms/aws/ecs-fargate/task-definition.json b/deploy/platforms/aws/ecs-fargate/task-definition.json new file mode 100644 index 00000000..f042bafd --- /dev/null +++ b/deploy/platforms/aws/ecs-fargate/task-definition.json @@ -0,0 +1,47 @@ +{ + "family": "finmind", + "networkMode": "awsvpc", + "requiresCompatibilities": ["FARGATE"], + "cpu": "512", + "memory": "1024", + "executionRoleArn": "arn:aws:iam::role/ecsTaskExecutionRole", + "taskRoleArn": "arn:aws:iam::role/ecsTaskRole", + "containerDefinitions": [ + { + "name": "backend", + "image": "ghcr.io/rohitdash08/finmind-backend:latest", + "essential": true, + "portMappings": [ + { + "containerPort": 8000, + "protocol": "tcp" + } + ], + "environment": [ + { "name": "LOG_LEVEL", "value": "INFO" }, + { "name": "GEMINI_MODEL", "value": "gemini-1.5-flash" } + ], + "secrets": [ + { "name": "DATABASE_URL", "valueFrom": "arn:aws:ssm:us-east-1::parameter/finmind/DATABASE_URL" }, + { "name": "REDIS_URL", "valueFrom": "arn:aws:ssm:us-east-1::parameter/finmind/REDIS_URL" }, + { "name": "JWT_SECRET", "valueFrom": "arn:aws:ssm:us-east-1::parameter/finmind/JWT_SECRET" }, + { "name": "GEMINI_API_KEY", "valueFrom": "arn:aws:ssm:us-east-1::parameter/finmind/GEMINI_API_KEY" } + ], + "healthCheck": { + "command": ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"], + "interval": 30, + "timeout": 5, + "retries": 3, + "startPeriod": 60 + }, + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/finmind", + "awslogs-region": "us-east-1", + "awslogs-stream-prefix": "backend" + } + } + } + ] +} diff --git a/deploy/platforms/azure/container-app.yaml b/deploy/platforms/azure/container-app.yaml new file mode 100644 index 00000000..670da774 --- /dev/null +++ b/deploy/platforms/azure/container-app.yaml @@ -0,0 +1,66 @@ +# Azure Container Apps configuration +# Deploy with: az containerapp create --yaml deploy/platforms/azure/container-app.yaml +type: Microsoft.App/containerApps +apiVersion: "2023-05-01" +location: eastus +properties: + managedEnvironmentId: /subscriptions/SUBSCRIPTION_ID/resourceGroups/finmind-rg/providers/Microsoft.App/managedEnvironments/finmind-env + configuration: + ingress: + external: true + targetPort: 8000 + transport: auto + allowInsecure: false + traffic: + - latestRevision: true + weight: 100 + secrets: + - name: database-url + value: "" + - name: redis-url + value: "" + - name: jwt-secret + value: "" + - name: gemini-api-key + value: "" + template: + containers: + - name: backend + image: ghcr.io/rohitdash08/finmind-backend:latest + resources: + cpu: 0.5 + memory: 1Gi + env: + - name: LOG_LEVEL + value: INFO + - name: GEMINI_MODEL + value: gemini-1.5-flash + - name: DATABASE_URL + secretRef: database-url + - name: REDIS_URL + secretRef: redis-url + - name: JWT_SECRET + secretRef: jwt-secret + - name: GEMINI_API_KEY + secretRef: gemini-api-key + probes: + - type: Readiness + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 10 + - type: Liveness + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 30 + periodSeconds: 30 + scale: + minReplicas: 1 + maxReplicas: 10 + rules: + - name: http-scaler + http: + metadata: + concurrentRequests: "100" diff --git a/deploy/platforms/azure/deploy.sh b/deploy/platforms/azure/deploy.sh new file mode 100755 index 00000000..26866a09 --- /dev/null +++ b/deploy/platforms/azure/deploy.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Azure Container Apps Deployment Script +# Prerequisites: az CLI logged in, subscription set +set -euo pipefail + +RESOURCE_GROUP="${AZURE_RESOURCE_GROUP:-finmind-rg}" +LOCATION="${AZURE_LOCATION:-eastus}" +ENV_NAME="finmind-env" +APP_NAME="finmind-backend" +ACR_NAME="${AZURE_ACR_NAME:-finmindacr}" + +echo "======================================" +echo " FinMind — Azure Container Apps Deploy" +echo "======================================" + +# 1. Create resource group +echo "[1/7] Creating resource group..." +az group create --name "$RESOURCE_GROUP" --location "$LOCATION" --output none 2>/dev/null || true + +# 2. Create ACR +echo "[2/7] Creating Container Registry..." +az acr create --resource-group "$RESOURCE_GROUP" --name "$ACR_NAME" \ + --sku Basic --admin-enabled true --output none 2>/dev/null || true + +# 3. Build and push image +echo "[3/7] Building and pushing image..." +az acr build --registry "$ACR_NAME" --image finmind-backend:latest \ + --file packages/backend/Dockerfile packages/backend/ + +# 4. Create Container Apps environment +echo "[4/7] Creating Container Apps environment..." +az containerapp env create \ + --name "$ENV_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --location "$LOCATION" \ + --output none 2>/dev/null || true + +# 5. Create PostgreSQL +echo "[5/7] Creating Azure Database for PostgreSQL..." +PG_SERVER="finmind-pg-${RANDOM}" +PG_PASS=$(openssl rand -base64 24) +az postgres flexible-server create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$PG_SERVER" \ + --location "$LOCATION" \ + --admin-user finmind \ + --admin-password "$PG_PASS" \ + --sku-name Standard_B1ms \ + --version 16 \ + --output none 2>/dev/null || echo " PostgreSQL already exists or creation in progress" + +# 6. Create Redis +echo "[6/7] Creating Azure Cache for Redis..." +az redis create \ + --resource-group "$RESOURCE_GROUP" \ + --name "finmind-redis" \ + --location "$LOCATION" \ + --sku Basic \ + --vm-size C0 \ + --output none 2>/dev/null || echo " Redis already exists or creation in progress" + +# 7. Deploy Container App +echo "[7/7] Deploying Container App..." +ACR_SERVER=$(az acr show --name "$ACR_NAME" --query loginServer --output tsv) +ACR_USER=$(az acr credential show --name "$ACR_NAME" --query username --output tsv) +ACR_PASS=$(az acr credential show --name "$ACR_NAME" --query 'passwords[0].value' --output tsv) + +az containerapp create \ + --name "$APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --environment "$ENV_NAME" \ + --image "${ACR_SERVER}/finmind-backend:latest" \ + --registry-server "$ACR_SERVER" \ + --registry-username "$ACR_USER" \ + --registry-password "$ACR_PASS" \ + --target-port 8000 \ + --ingress external \ + --min-replicas 1 \ + --max-replicas 10 \ + --cpu 0.5 \ + --memory 1Gi \ + --env-vars "LOG_LEVEL=INFO" "GEMINI_MODEL=gemini-1.5-flash" \ + --output none + +FQDN=$(az containerapp show --name "$APP_NAME" --resource-group "$RESOURCE_GROUP" \ + --query properties.configuration.ingress.fqdn --output tsv) + +echo "" +echo "Deployed: https://${FQDN}" +echo "Health: https://${FQDN}/health" +echo "" +echo "NOTE: Set DATABASE_URL, REDIS_URL, JWT_SECRET via:" +echo " az containerapp update --name $APP_NAME --resource-group $RESOURCE_GROUP --set-env-vars 'DATABASE_URL=secretref:...' " diff --git a/deploy/platforms/digitalocean/app-platform/do-app-spec.yaml b/deploy/platforms/digitalocean/app-platform/do-app-spec.yaml new file mode 100644 index 00000000..01232601 --- /dev/null +++ b/deploy/platforms/digitalocean/app-platform/do-app-spec.yaml @@ -0,0 +1,67 @@ +# DigitalOcean App Platform Spec +# Deploy: doctl apps create --spec deploy/platforms/digitalocean/app-platform/do-app-spec.yaml +name: finmind +region: nyc + +services: + - name: backend + dockerfile_path: packages/backend/Dockerfile + github: + repo: rohitdash08/FinMind + branch: main + deploy_on_push: true + http_port: 8000 + instance_count: 1 + instance_size_slug: basic-xxs + health_check: + http_path: /health + initial_delay_seconds: 30 + period_seconds: 15 + envs: + - key: DATABASE_URL + scope: RUN_TIME + value: "${db.DATABASE_URL}" + - key: REDIS_URL + scope: RUN_TIME + value: "${redis.REDIS_URL}" + - key: JWT_SECRET + scope: RUN_TIME + type: SECRET + value: "CHANGE_ME_BEFORE_DEPLOY" + - key: GEMINI_API_KEY + scope: RUN_TIME + type: SECRET + value: "" + - key: GEMINI_MODEL + scope: RUN_TIME + value: gemini-1.5-flash + - key: LOG_LEVEL + scope: RUN_TIME + value: INFO + +static_sites: + - name: frontend + build_command: cd app && npm ci && npm run build + output_dir: app/dist + github: + repo: rohitdash08/FinMind + branch: main + deploy_on_push: true + catchall_document: index.html + envs: + - key: VITE_API_URL + scope: BUILD_TIME + value: "${backend.PUBLIC_URL}" + +databases: + - name: db + engine: PG + version: "16" + size: db-s-dev-database + num_nodes: 1 + + - name: redis + engine: REDIS + version: "7" + size: db-s-dev-database + num_nodes: 1 diff --git a/deploy/platforms/digitalocean/droplet/setup.sh b/deploy/platforms/digitalocean/droplet/setup.sh new file mode 100755 index 00000000..03580f7e --- /dev/null +++ b/deploy/platforms/digitalocean/droplet/setup.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# DigitalOcean Droplet One-Line Setup +# Usage: curl -sSL https://raw.githubusercontent.com/rohitdash08/FinMind/main/deploy/platforms/digitalocean/droplet/setup.sh | bash +set -euo pipefail + +echo "==========================================" +echo " FinMind — DigitalOcean Droplet Setup" +echo "==========================================" + +# Check if running as root +if [ "$(id -u)" -ne 0 ]; then + echo "ERROR: This script must be run as root (or with sudo)." + exit 1 +fi + +# ─── Install Docker ─── +if ! command -v docker &>/dev/null; then + echo "[1/5] Installing Docker..." + curl -fsSL https://get.docker.com | sh + systemctl enable docker + systemctl start docker +else + echo "[1/5] Docker already installed." +fi + +# ─── Install Docker Compose ─── +if ! docker compose version &>/dev/null; then + echo "[2/5] Installing Docker Compose plugin..." + apt-get update -qq && apt-get install -y -qq docker-compose-plugin +else + echo "[2/5] Docker Compose already installed." +fi + +# ─── Clone FinMind ─── +INSTALL_DIR="/opt/finmind" +if [ -d "$INSTALL_DIR" ]; then + echo "[3/5] Updating existing installation..." + cd "$INSTALL_DIR" && git pull +else + echo "[3/5] Cloning FinMind..." + git clone https://github.com/rohitdash08/FinMind.git "$INSTALL_DIR" + cd "$INSTALL_DIR" +fi + +# ─── Configure Environment ─── +if [ ! -f .env ]; then + echo "[4/5] Creating .env from template..." + cp .env.example .env + # Generate secure JWT secret + JWT_SECRET=$(openssl rand -hex 32) + sed -i "s|JWT_SECRET=.*|JWT_SECRET=$JWT_SECRET|" .env + # Generate secure postgres password + PG_PASS=$(openssl rand -hex 16) + sed -i "s|POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$PG_PASS|" .env + sed -i "s|postgresql+psycopg2://finmind:finmind@|postgresql+psycopg2://finmind:${PG_PASS}@|" .env + echo " Generated secure secrets in .env" +else + echo "[4/5] .env already exists, keeping current config." +fi + +# ─── Start Services ─── +echo "[5/5] Starting FinMind..." +docker compose up -d --build + +echo "" +echo "==========================================" +echo " FinMind is running!" +echo "" +echo " Frontend: http://$(hostname -I | awk '{print $1}'):5173" +echo " Backend: http://$(hostname -I | awk '{print $1}'):8000" +echo " Grafana: http://$(hostname -I | awk '{print $1}'):3000" +echo "" +echo " Manage: cd $INSTALL_DIR && docker compose logs -f" +echo "==========================================" diff --git a/deploy/platforms/flyio/backend/fly.toml b/deploy/platforms/flyio/backend/fly.toml new file mode 100644 index 00000000..caa9d771 --- /dev/null +++ b/deploy/platforms/flyio/backend/fly.toml @@ -0,0 +1,42 @@ +app = "finmind-backend" +primary_region = "iad" +kill_signal = "SIGTERM" +kill_timeout = "30s" + +[build] + dockerfile = "packages/backend/Dockerfile" + +[deploy] + release_command = "python -m flask --app wsgi:app init-db" + strategy = "rolling" + +[env] + LOG_LEVEL = "INFO" + GEMINI_MODEL = "gemini-1.5-flash" + PORT = "8000" + +[http_service] + internal_port = 8000 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 1 + processes = ["app"] + + [http_service.concurrency] + type = "requests" + hard_limit = 250 + soft_limit = 200 + +[[http_service.checks]] + interval = "15s" + timeout = "5s" + grace_period = "30s" + method = "GET" + path = "/health" + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" + cpu_kind = "shared" + cpus = 1 diff --git a/deploy/platforms/flyio/frontend/fly.toml b/deploy/platforms/flyio/frontend/fly.toml new file mode 100644 index 00000000..70826933 --- /dev/null +++ b/deploy/platforms/flyio/frontend/fly.toml @@ -0,0 +1,28 @@ +app = "finmind-frontend" +primary_region = "iad" +kill_signal = "SIGTERM" +kill_timeout = "10s" + +[build] + dockerfile = "app/Dockerfile" + +[http_service] + internal_port = 80 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 1 + processes = ["app"] + +[[http_service.checks]] + interval = "30s" + timeout = "5s" + grace_period = "10s" + method = "GET" + path = "/" + +[[vm]] + size = "shared-cpu-1x" + memory = "256mb" + cpu_kind = "shared" + cpus = 1 diff --git a/deploy/platforms/gcp/cloudrun.yaml b/deploy/platforms/gcp/cloudrun.yaml new file mode 100644 index 00000000..cd78bb5f --- /dev/null +++ b/deploy/platforms/gcp/cloudrun.yaml @@ -0,0 +1,62 @@ +# GCP Cloud Run Service Definition +# Deploy with: gcloud run services replace deploy/platforms/gcp/cloudrun.yaml +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: finmind-backend + annotations: + run.googleapis.com/ingress: all + run.googleapis.com/launch-stage: GA +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/minScale: "1" + autoscaling.knative.dev/maxScale: "10" + run.googleapis.com/cpu-throttling: "false" + run.googleapis.com/startup-cpu-boost: "true" + run.googleapis.com/vpc-access-connector: projects/PROJECT_ID/locations/REGION/connectors/finmind-vpc + spec: + containerConcurrency: 100 + timeoutSeconds: 300 + serviceAccountName: finmind-sa@PROJECT_ID.iam.gserviceaccount.com + containers: + - image: gcr.io/PROJECT_ID/finmind-backend:latest + ports: + - containerPort: 8000 + env: + - name: LOG_LEVEL + value: INFO + - name: GEMINI_MODEL + value: gemini-1.5-flash + - name: DATABASE_URL + valueFrom: + secretKeyRef: + key: latest + name: finmind-database-url + - name: REDIS_URL + valueFrom: + secretKeyRef: + key: latest + name: finmind-redis-url + - name: JWT_SECRET + valueFrom: + secretKeyRef: + key: latest + name: finmind-jwt-secret + resources: + limits: + cpu: "1" + memory: 512Mi + startupProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /health + port: 8000 + periodSeconds: 30 diff --git a/deploy/platforms/gcp/deploy.sh b/deploy/platforms/gcp/deploy.sh new file mode 100755 index 00000000..1f6da134 --- /dev/null +++ b/deploy/platforms/gcp/deploy.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# GCP Cloud Run Deployment Script +# Prerequisites: gcloud CLI authenticated, project configured +set -euo pipefail + +PROJECT_ID="${GCP_PROJECT_ID:?Set GCP_PROJECT_ID}" +REGION="${GCP_REGION:-us-central1}" +SERVICE_NAME="finmind-backend" +IMAGE="gcr.io/${PROJECT_ID}/${SERVICE_NAME}" + +echo "======================================" +echo " FinMind — GCP Cloud Run Deploy" +echo "======================================" + +# 1. Enable required APIs +echo "[1/6] Enabling GCP APIs..." +gcloud services enable \ + run.googleapis.com \ + cloudbuild.googleapis.com \ + secretmanager.googleapis.com \ + sqladmin.googleapis.com \ + redis.googleapis.com \ + --project "$PROJECT_ID" --quiet + +# 2. Build with Cloud Build +echo "[2/6] Building with Cloud Build..." +gcloud builds submit \ + --tag "$IMAGE:latest" \ + --project "$PROJECT_ID" \ + packages/backend/ + +# 3. Create secrets (first-time only) +echo "[3/6] Setting up secrets..." +for secret in finmind-database-url finmind-redis-url finmind-jwt-secret finmind-gemini-key; do + if ! gcloud secrets describe "$secret" --project "$PROJECT_ID" &>/dev/null; then + echo " Creating secret: $secret (set value manually)" + echo -n "placeholder" | gcloud secrets create "$secret" \ + --data-file=- --project "$PROJECT_ID" --quiet + fi +done + +# 4. Create Cloud SQL instance (first-time only) +echo "[4/6] Checking Cloud SQL..." +if ! gcloud sql instances describe finmind-db --project "$PROJECT_ID" &>/dev/null; then + echo " Creating Cloud SQL PostgreSQL 16 instance..." + gcloud sql instances create finmind-db \ + --database-version=POSTGRES_16 \ + --tier=db-f1-micro \ + --region="$REGION" \ + --project "$PROJECT_ID" \ + --quiet + gcloud sql databases create finmind --instance=finmind-db --project "$PROJECT_ID" --quiet + gcloud sql users create finmind --instance=finmind-db --password="$(openssl rand -hex 16)" --project "$PROJECT_ID" --quiet +fi + +# 5. Create Memorystore Redis (first-time only) +echo "[5/6] Checking Memorystore Redis..." +if ! gcloud redis instances describe finmind-redis --region="$REGION" --project "$PROJECT_ID" &>/dev/null; then + echo " Creating Memorystore Redis instance..." + gcloud redis instances create finmind-redis \ + --size=1 \ + --region="$REGION" \ + --redis-version=redis_7_0 \ + --project "$PROJECT_ID" \ + --quiet +fi + +# 6. Deploy to Cloud Run +echo "[6/6] Deploying to Cloud Run..." +gcloud run deploy "$SERVICE_NAME" \ + --image "$IMAGE:latest" \ + --platform managed \ + --region "$REGION" \ + --port 8000 \ + --min-instances 1 \ + --max-instances 10 \ + --memory 512Mi \ + --cpu 1 \ + --set-env-vars "LOG_LEVEL=INFO,GEMINI_MODEL=gemini-1.5-flash" \ + --set-secrets "DATABASE_URL=finmind-database-url:latest,REDIS_URL=finmind-redis-url:latest,JWT_SECRET=finmind-jwt-secret:latest,GEMINI_API_KEY=finmind-gemini-key:latest" \ + --allow-unauthenticated \ + --project "$PROJECT_ID" \ + --quiet + +SERVICE_URL=$(gcloud run services describe "$SERVICE_NAME" --region "$REGION" --project "$PROJECT_ID" --format 'value(status.url)') +echo "" +echo "Deployed: $SERVICE_URL" +echo "Health: $SERVICE_URL/health" diff --git a/deploy/platforms/heroku/Procfile b/deploy/platforms/heroku/Procfile new file mode 100644 index 00000000..e3a28573 --- /dev/null +++ b/deploy/platforms/heroku/Procfile @@ -0,0 +1,2 @@ +web: sh -c 'python -m flask --app wsgi:app init-db && gunicorn --workers=2 --threads=4 --bind 0.0.0.0:$PORT wsgi:app' +release: python -m flask --app wsgi:app init-db diff --git a/deploy/platforms/heroku/app.json b/deploy/platforms/heroku/app.json new file mode 100644 index 00000000..3ae2047f --- /dev/null +++ b/deploy/platforms/heroku/app.json @@ -0,0 +1,46 @@ +{ + "name": "FinMind", + "description": "Personal Finance Management Platform — track expenses, bills, reminders, and get AI-powered insights", + "repository": "https://github.com/rohitdash08/FinMind", + "logo": "", + "keywords": ["finance", "budgeting", "expenses", "flask", "react"], + "stack": "container", + "env": { + "JWT_SECRET": { + "description": "Secret key for JWT token signing", + "generator": "secret" + }, + "GEMINI_API_KEY": { + "description": "Google Gemini API key for AI insights (optional)", + "required": false, + "value": "" + }, + "GEMINI_MODEL": { + "description": "Gemini model to use", + "value": "gemini-1.5-flash" + }, + "LOG_LEVEL": { + "description": "Application log level", + "value": "INFO" + }, + "OPENAI_API_KEY": { + "description": "OpenAI API key (optional)", + "required": false, + "value": "" + } + }, + "addons": [ + "heroku-postgresql:essential-0", + "heroku-redis:mini" + ], + "formation": { + "web": { + "quantity": 1, + "size": "basic" + } + }, + "buildpacks": [], + "scripts": { + "postdeploy": "python -m flask --app wsgi:app init-db" + } +} diff --git a/deploy/platforms/heroku/heroku.yml b/deploy/platforms/heroku/heroku.yml new file mode 100644 index 00000000..546d8edf --- /dev/null +++ b/deploy/platforms/heroku/heroku.yml @@ -0,0 +1,13 @@ +build: + docker: + web: packages/backend/Dockerfile + config: + WORKDIR: packages/backend +run: + web: sh -c 'python -m flask --app wsgi:app init-db && gunicorn --workers=2 --threads=4 --bind 0.0.0.0:$PORT wsgi:app' +release: + command: + - python -m flask --app wsgi:app init-db +addons: + - plan: heroku-postgresql:essential-0 + - plan: heroku-redis:mini diff --git a/deploy/platforms/netlify/netlify.toml b/deploy/platforms/netlify/netlify.toml new file mode 100644 index 00000000..4ab36ef9 --- /dev/null +++ b/deploy/platforms/netlify/netlify.toml @@ -0,0 +1,43 @@ +# Netlify configuration for FinMind Frontend +[build] + base = "app" + command = "npm ci && npm run build" + publish = "dist" + +# SPA fallback — all routes serve index.html +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 + +# API proxy to backend (set BACKEND_URL in Netlify env vars) +[[redirects]] + from = "/api/*" + to = ":BACKEND_URL/api/:splat" + status = 200 + force = true + +# Security headers +[[headers]] + for = "/*" + [headers.values] + X-Frame-Options = "SAMEORIGIN" + X-Content-Type-Options = "nosniff" + X-XSS-Protection = "1; mode=block" + Referrer-Policy = "strict-origin-when-cross-origin" + Permissions-Policy = "camera=(), microphone=(), geolocation=()" + +# Cache static assets aggressively +[[headers]] + for = "/assets/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +# Cache fonts +[[headers]] + for = "*.woff2" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +[build.environment] + NODE_VERSION = "20" diff --git a/deploy/platforms/railway/railway.toml b/deploy/platforms/railway/railway.toml new file mode 100644 index 00000000..190eca6e --- /dev/null +++ b/deploy/platforms/railway/railway.toml @@ -0,0 +1,22 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "packages/backend/Dockerfile" + +[deploy] +startCommand = "sh -c 'python -m flask --app wsgi:app init-db && gunicorn --workers=2 --threads=4 --bind 0.0.0.0:${PORT:-8000} wsgi:app'" +healthcheckPath = "/health" +healthcheckTimeout = 30 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 3 + +[[services]] +name = "finmind-backend" +internalPort = 8000 +protocol = "TCP" + +[variables] +DATABASE_URL = "${{Postgres.DATABASE_URL}}" +REDIS_URL = "${{Redis.REDIS_URL}}" +JWT_SECRET = "${{shared.JWT_SECRET}}" +LOG_LEVEL = "INFO" +GEMINI_MODEL = "gemini-1.5-flash" diff --git a/deploy/platforms/render/render.yaml b/deploy/platforms/render/render.yaml new file mode 100644 index 00000000..cac99e73 --- /dev/null +++ b/deploy/platforms/render/render.yaml @@ -0,0 +1,59 @@ +# Render Blueprint — https://render.com/docs/blueprint-spec +services: + - type: web + name: finmind-backend + runtime: docker + dockerfilePath: packages/backend/Dockerfile + dockerContext: . + plan: starter + healthCheckPath: /health + envVars: + - key: DATABASE_URL + fromDatabase: + name: finmind-db + property: connectionString + - key: REDIS_URL + fromService: + name: finmind-redis + type: redis + property: connectionString + - key: JWT_SECRET + generateValue: true + - key: GEMINI_API_KEY + value: "" + - key: GEMINI_MODEL + value: gemini-1.5-flash + - key: LOG_LEVEL + value: INFO + + - type: web + name: finmind-frontend + runtime: static + buildCommand: cd app && npm ci && npm run build + staticPublishPath: app/dist + headers: + - path: /* + name: X-Frame-Options + value: SAMEORIGIN + routes: + - type: rewrite + source: /* + destination: /index.html + envVars: + - key: VITE_API_URL + fromService: + name: finmind-backend + type: web + property: host + +databases: + - name: finmind-db + plan: starter + databaseName: finmind + user: finmind + postgresMajorVersion: "16" + + - name: finmind-redis + plan: starter + type: redis + maxmemoryPolicy: allkeys-lru diff --git a/deploy/platforms/vercel/vercel.json b/deploy/platforms/vercel/vercel.json new file mode 100644 index 00000000..d04f9b5a --- /dev/null +++ b/deploy/platforms/vercel/vercel.json @@ -0,0 +1,28 @@ +{ + "version": 2, + "name": "finmind-frontend", + "framework": "vite", + "buildCommand": "cd app && npm ci && npm run build", + "outputDirectory": "app/dist", + "installCommand": "cd app && npm ci", + "rewrites": [ + { "source": "/(.*)", "destination": "/index.html" } + ], + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "X-Frame-Options", "value": "SAMEORIGIN" }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-XSS-Protection", "value": "1; mode=block" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } + ] + }, + { + "source": "/assets/(.*)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + } + ] +} diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh new file mode 100755 index 00000000..949a5024 --- /dev/null +++ b/deploy/scripts/deploy.sh @@ -0,0 +1,326 @@ +#!/usr/bin/env bash +# ╔══════════════════════════════════════════════════════════════╗ +# ║ FinMind — Universal One-Click Deployment Dispatcher ║ +# ║ Usage: ./deploy/scripts/deploy.sh ║ +# ╚══════════════════════════════════════════════════════════════╝ +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$ROOT_DIR" + +# ─── Colors ─── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +print_header() { + echo -e "${BLUE}" + echo "╔══════════════════════════════════════════╗" + echo "║ FinMind Deploy Dispatcher ║" + echo "╚══════════════════════════════════════════╝" + echo -e "${NC}" +} + +print_usage() { + echo "Usage: $0 " + echo "" + echo "Platforms:" + echo " docker-compose Local Docker Compose (recommended for development)" + echo " kubernetes Kubernetes via Helm chart" + echo " tilt Local K8s dev with Tilt (hot-reload)" + echo "" + echo " railway Railway PaaS" + echo " heroku Heroku (Docker stack)" + echo " render Render Blueprint" + echo " flyio Fly.io" + echo " digitalocean DigitalOcean App Platform" + echo " do-droplet DigitalOcean Droplet (Docker Compose)" + echo "" + echo " aws-ecs AWS ECS Fargate" + echo " aws-apprunner AWS App Runner" + echo " gcp-cloudrun GCP Cloud Run" + echo " azure Azure Container Apps" + echo "" + echo " netlify Netlify (frontend only)" + echo " vercel Vercel (frontend only)" + echo "" + echo " smoke-test Run smoke tests against a running deployment" + echo " validate Validate all deployment configs" +} + +check_command() { + if ! command -v "$1" &>/dev/null; then + echo -e "${RED}ERROR: '$1' is required but not installed.${NC}" + echo "Install it first: $2" + exit 1 + fi +} + +# ─── Pre-flight checks ─── +preflight_docker() { + check_command docker "https://docs.docker.com/get-docker/" + if ! docker info &>/dev/null; then + echo -e "${RED}ERROR: Docker daemon is not running.${NC}" + exit 1 + fi +} + +preflight_k8s() { + check_command kubectl "https://kubernetes.io/docs/tasks/tools/" + check_command helm "https://helm.sh/docs/intro/install/" + if ! kubectl cluster-info &>/dev/null; then + echo -e "${RED}ERROR: Cannot connect to Kubernetes cluster.${NC}" + exit 1 + fi +} + +# ─── Deploy functions ─── +deploy_docker_compose() { + preflight_docker + echo -e "${GREEN}Deploying with Docker Compose...${NC}" + + if [ ! -f .env ]; then + echo -e "${YELLOW}Creating .env from .env.example...${NC}" + cp .env.example .env + fi + + docker compose up -d --build + + echo -e "${GREEN}Done! Services:${NC}" + echo " Frontend: http://localhost:5173" + echo " Backend: http://localhost:8000" + echo " Grafana: http://localhost:3000" + echo "" + echo "Run smoke tests: $0 smoke-test" +} + +deploy_kubernetes() { + preflight_k8s + echo -e "${GREEN}Deploying to Kubernetes via Helm...${NC}" + + # Install or upgrade + helm upgrade --install finmind deploy/helm/finmind \ + --namespace finmind \ + --create-namespace \ + --wait \ + --timeout 5m + + echo -e "${GREEN}Done! Check status:${NC}" + echo " kubectl get pods -n finmind" + echo " kubectl get svc -n finmind" + echo " kubectl get ingress -n finmind" +} + +deploy_tilt() { + check_command tilt "https://docs.tilt.dev/install.html" + preflight_docker + echo -e "${GREEN}Starting Tilt local K8s dev environment...${NC}" + echo " This will build images, deploy to local K8s, and start live-reload." + echo "" + tilt up +} + +deploy_railway() { + check_command railway "https://docs.railway.app/develop/cli" + echo -e "${GREEN}Deploying to Railway...${NC}" + cp deploy/platforms/railway/railway.toml . + railway up + rm -f railway.toml +} + +deploy_heroku() { + check_command heroku "https://devcenter.heroku.com/articles/heroku-cli" + echo -e "${GREEN}Deploying to Heroku...${NC}" + cp deploy/platforms/heroku/Procfile . + cp deploy/platforms/heroku/heroku.yml . + heroku container:push web --recursive + heroku container:release web + rm -f Procfile heroku.yml +} + +deploy_render() { + echo -e "${GREEN}Deploying to Render...${NC}" + echo "Render uses render.yaml for Blueprint deployments." + echo "" + echo "Steps:" + echo " 1. Push this repo to GitHub" + echo " 2. Go to https://dashboard.render.com/blueprints" + echo " 3. Click 'New Blueprint Instance'" + echo " 4. Select this repo — Render will auto-detect deploy/platforms/render/render.yaml" + echo "" + echo "Or copy render.yaml to repo root:" + echo " cp deploy/platforms/render/render.yaml ." +} + +deploy_flyio() { + check_command fly "https://fly.io/docs/hands-on/install-flyctl/" + echo -e "${GREEN}Deploying to Fly.io...${NC}" + + echo " Deploying backend..." + fly deploy --config deploy/platforms/flyio/backend/fly.toml + + echo " Deploying frontend..." + fly deploy --config deploy/platforms/flyio/frontend/fly.toml + + echo -e "${GREEN}Done!${NC}" + fly status --config deploy/platforms/flyio/backend/fly.toml +} + +deploy_digitalocean() { + check_command doctl "https://docs.digitalocean.com/reference/doctl/how-to/install/" + echo -e "${GREEN}Deploying to DigitalOcean App Platform...${NC}" + doctl apps create --spec deploy/platforms/digitalocean/app-platform/do-app-spec.yaml +} + +deploy_do_droplet() { + echo -e "${GREEN}Deploying to DigitalOcean Droplet...${NC}" + echo "Run on the droplet:" + echo " curl -sSL https://raw.githubusercontent.com/rohitdash08/FinMind/main/deploy/platforms/digitalocean/droplet/setup.sh | sudo bash" +} + +deploy_aws_ecs() { + check_command aws "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html" + preflight_docker + echo -e "${GREEN}Deploying to AWS ECS Fargate...${NC}" + bash deploy/platforms/aws/ecs-fargate/deploy.sh +} + +deploy_aws_apprunner() { + check_command aws "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html" + echo -e "${GREEN}Deploying to AWS App Runner...${NC}" + echo "Use the AWS Console or CLI:" + echo " aws apprunner create-service --cli-input-yaml file://deploy/platforms/aws/app-runner/apprunner.yaml" +} + +deploy_gcp_cloudrun() { + check_command gcloud "https://cloud.google.com/sdk/docs/install" + echo -e "${GREEN}Deploying to GCP Cloud Run...${NC}" + bash deploy/platforms/gcp/deploy.sh +} + +deploy_azure() { + check_command az "https://learn.microsoft.com/en-us/cli/azure/install-azure-cli" + echo -e "${GREEN}Deploying to Azure Container Apps...${NC}" + bash deploy/platforms/azure/deploy.sh +} + +deploy_netlify() { + check_command netlify "npm install -g netlify-cli" + echo -e "${GREEN}Deploying frontend to Netlify...${NC}" + cp deploy/platforms/netlify/netlify.toml app/ + cd app && netlify deploy --prod + rm -f netlify.toml +} + +deploy_vercel() { + check_command vercel "npm install -g vercel" + echo -e "${GREEN}Deploying frontend to Vercel...${NC}" + cp deploy/platforms/vercel/vercel.json . + vercel --prod + rm -f vercel.json +} + +run_smoke_test() { + BACKEND_URL="${1:-http://localhost:8000}" + FRONTEND_URL="${2:-http://localhost:5173}" + bash "$SCRIPT_DIR/smoke-test.sh" "$BACKEND_URL" "$FRONTEND_URL" +} + +validate_configs() { + echo -e "${BLUE}Validating deployment configurations...${NC}" + ERRORS=0 + + # Validate Helm chart + if command -v helm &>/dev/null; then + echo -n " Helm chart lint: " + if helm lint deploy/helm/finmind &>/dev/null; then + echo -e "${GREEN}PASS${NC}" + else + echo -e "${RED}FAIL${NC}" + ERRORS=$((ERRORS + 1)) + fi + fi + + # Check required files exist + for f in \ + deploy/platforms/railway/railway.toml \ + deploy/platforms/heroku/Procfile \ + deploy/platforms/heroku/app.json \ + deploy/platforms/render/render.yaml \ + deploy/platforms/flyio/backend/fly.toml \ + deploy/platforms/flyio/frontend/fly.toml \ + deploy/platforms/digitalocean/app-platform/do-app-spec.yaml \ + deploy/platforms/digitalocean/droplet/setup.sh \ + deploy/platforms/aws/ecs-fargate/task-definition.json \ + deploy/platforms/aws/app-runner/apprunner.yaml \ + deploy/platforms/gcp/cloudrun.yaml \ + deploy/platforms/azure/container-app.yaml \ + deploy/platforms/netlify/netlify.toml \ + deploy/platforms/vercel/vercel.json \ + Tiltfile \ + ; do + echo -n " $f: " + if [ -f "$f" ]; then + echo -e "${GREEN}EXISTS${NC}" + else + echo -e "${RED}MISSING${NC}" + ERRORS=$((ERRORS + 1)) + fi + done + + # Validate JSON files + for f in deploy/platforms/aws/ecs-fargate/task-definition.json deploy/platforms/vercel/vercel.json deploy/platforms/heroku/app.json; do + echo -n " JSON valid ($f): " + if python3 -m json.tool "$f" &>/dev/null; then + echo -e "${GREEN}PASS${NC}" + else + echo -e "${RED}FAIL${NC}" + ERRORS=$((ERRORS + 1)) + fi + done + + echo "" + if [ "$ERRORS" -eq 0 ]; then + echo -e "${GREEN}All validations passed!${NC}" + else + echo -e "${RED}$ERRORS validation(s) failed.${NC}" + exit 1 + fi +} + +# ─── Main ─── +print_header + +if [ $# -eq 0 ]; then + print_usage + exit 0 +fi + +case "$1" in + docker-compose|docker|compose) deploy_docker_compose ;; + kubernetes|k8s|helm) deploy_kubernetes ;; + tilt) deploy_tilt ;; + railway) deploy_railway ;; + heroku) deploy_heroku ;; + render) deploy_render ;; + flyio|fly) deploy_flyio ;; + digitalocean|do) deploy_digitalocean ;; + do-droplet|droplet) deploy_do_droplet ;; + aws-ecs|ecs) deploy_aws_ecs ;; + aws-apprunner|apprunner) deploy_aws_apprunner ;; + gcp-cloudrun|cloudrun|gcp) deploy_gcp_cloudrun ;; + azure) deploy_azure ;; + netlify) deploy_netlify ;; + vercel) deploy_vercel ;; + smoke-test|test) run_smoke_test "${2:-}" "${3:-}" ;; + validate) validate_configs ;; + *) + echo -e "${RED}Unknown platform: $1${NC}" + echo "" + print_usage + exit 1 + ;; +esac diff --git a/deploy/scripts/smoke-test.sh b/deploy/scripts/smoke-test.sh new file mode 100755 index 00000000..15b67dcf --- /dev/null +++ b/deploy/scripts/smoke-test.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# ╔══════════════════════════════════════════════════════════════╗ +# ║ FinMind — Runtime Smoke Test Suite ║ +# ║ Validates all acceptance criteria from issue #144 ║ +# ╚══════════════════════════════════════════════════════════════╝ +set -euo pipefail + +BACKEND_URL="${1:-http://localhost:8000}" +FRONTEND_URL="${2:-http://localhost:5173}" +PASS=0 +FAIL=0 +WARN=0 + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +check() { + local name="$1" + local url="$2" + local expect="${3:-200}" + + echo -n " [$name] $url ... " + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$url" 2>/dev/null || echo "000") + + if [ "$HTTP_CODE" = "$expect" ]; then + echo -e "${GREEN}PASS${NC} (HTTP $HTTP_CODE)" + PASS=$((PASS + 1)) + elif [ "$HTTP_CODE" = "000" ]; then + echo -e "${RED}FAIL${NC} (connection refused)" + FAIL=$((FAIL + 1)) + else + echo -e "${YELLOW}WARN${NC} (HTTP $HTTP_CODE, expected $expect)" + WARN=$((WARN + 1)) + fi +} + +check_json() { + local name="$1" + local url="$2" + local field="$3" + + echo -n " [$name] $url .$field ... " + RESPONSE=$(curl -s --max-time 10 "$url" 2>/dev/null || echo "{}") + VALUE=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('$field',''))" 2>/dev/null || echo "") + + if [ -n "$VALUE" ] && [ "$VALUE" != "None" ]; then + echo -e "${GREEN}PASS${NC} ($field=$VALUE)" + PASS=$((PASS + 1)) + else + echo -e "${RED}FAIL${NC} (field '$field' not found)" + FAIL=$((FAIL + 1)) + fi +} + +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ FinMind Smoke Test Suite ║" +echo "╚══════════════════════════════════════════╝" +echo "" +echo "Backend: $BACKEND_URL" +echo "Frontend: $FRONTEND_URL" +echo "" + +# ── 1. Frontend reachable ── +echo "1. Frontend Reachability" +check "Frontend root" "$FRONTEND_URL" +check "Frontend assets" "$FRONTEND_URL/index.html" +echo "" + +# ── 2. Backend health reachable ── +echo "2. Backend Health" +check "Health endpoint" "$BACKEND_URL/health" +check "Metrics endpoint" "$BACKEND_URL/metrics" +echo "" + +# ── 3. DB + Redis connected (health endpoint returns connection status) ── +echo "3. Database & Redis Connectivity" +check_json "Health DB check" "$BACKEND_URL/health" "status" +echo "" + +# ── 4. Auth flows ── +echo "4. Authentication Flows" +# Test registration endpoint exists +echo -n " [Auth register] POST $BACKEND_URL/auth/register ... " +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \ + -H "Content-Type: application/json" \ + -d '{"email":"smoke-test@test.com","password":"Test123!","name":"Smoke Test"}' \ + "$BACKEND_URL/auth/register" 2>/dev/null || echo "000") +if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "409" ] || [ "$HTTP_CODE" = "422" ] || [ "$HTTP_CODE" = "400" ]; then + echo -e "${GREEN}PASS${NC} (HTTP $HTTP_CODE — endpoint responsive)" + PASS=$((PASS + 1)) +elif [ "$HTTP_CODE" = "000" ]; then + echo -e "${RED}FAIL${NC} (connection refused)" + FAIL=$((FAIL + 1)) +else + echo -e "${YELLOW}WARN${NC} (HTTP $HTTP_CODE)" + WARN=$((WARN + 1)) +fi + +# Test login endpoint exists +echo -n " [Auth login] POST $BACKEND_URL/auth/login ... " +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \ + -H "Content-Type: application/json" \ + -d '{"email":"smoke-test@test.com","password":"Test123!"}' \ + "$BACKEND_URL/auth/login" 2>/dev/null || echo "000") +if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "422" ] || [ "$HTTP_CODE" = "400" ]; then + echo -e "${GREEN}PASS${NC} (HTTP $HTTP_CODE — endpoint responsive)" + PASS=$((PASS + 1)) +elif [ "$HTTP_CODE" = "000" ]; then + echo -e "${RED}FAIL${NC} (connection refused)" + FAIL=$((FAIL + 1)) +else + echo -e "${YELLOW}WARN${NC} (HTTP $HTTP_CODE)" + WARN=$((WARN + 1)) +fi +echo "" + +# ── 5. Core modules ── +echo "5. Core Module Endpoints" +for endpoint in expenses bills reminders dashboard insights; do + check "Module: $endpoint" "$BACKEND_URL/${endpoint}" "401" # 401 expected without auth token +done +echo "" + +# ── Summary ── +TOTAL=$((PASS + FAIL + WARN)) +echo "══════════════════════════════════════════" +echo -e " Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}, ${YELLOW}$WARN warnings${NC} / $TOTAL total" +echo "══════════════════════════════════════════" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi