diff --git a/.do/app.yaml b/.do/app.yaml new file mode 100644 index 00000000..07935f7e --- /dev/null +++ b/.do/app.yaml @@ -0,0 +1,63 @@ +spec: + name: finmind + region: nyc + features: + - buildpack-stack=ubuntu-22 + + databases: + - engine: PG + name: finmind-db + num_nodes: 1 + size: db-s-dev-database + version: "16" + - engine: REDIS + name: finmind-redis + num_nodes: 1 + size: db-s-dev-database + version: "7" + + services: + # ── Backend API ── + - name: finmind-backend + dockerfile_path: packages/backend/Dockerfile + source_dir: / + http_port: 8000 + instance_count: 1 + instance_size_slug: basic-xxs + run_command: >- + sh -c "python -m flask --app wsgi:app init-db && + gunicorn -w 2 -k gthread --timeout 120 -b 0.0.0.0:8000 wsgi:app" + health_check: + http_path: /health + initial_delay_seconds: 30 + period_seconds: 15 + timeout_seconds: 5 + success_threshold: 1 + failure_threshold: 3 + envs: + - key: DATABASE_URL + scope: RUN_TIME + value: ${finmind-db.DATABASE_URL} + - key: REDIS_URL + scope: RUN_TIME + value: ${finmind-redis.DATABASE_URL} + - key: JWT_SECRET + scope: RUN_TIME + type: SECRET + value: "CHANGE_ME_IN_PRODUCTION" + - key: LOG_LEVEL + scope: RUN_TIME + value: INFO + + # ── Frontend Static Site ── + - name: finmind-frontend + dockerfile_path: app/Dockerfile + source_dir: / + http_port: 80 + instance_count: 1 + instance_size_slug: basic-xxs + build_command: "" + envs: + - key: VITE_API_URL + scope: BUILD_TIME + value: ${finmind-backend.PUBLIC_URL} diff --git a/.gitignore b/.gitignore index cf59987d..5873349f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,11 +36,8 @@ pnpm-debug.log* plan.md checklist.md continuation_prompt.md -deployment.md SESSION_SUMMARY.md -docker-compose.prod.yml - FEATURES.md create_issues.ps1 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 00000000..2b1d1aef --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,630 @@ +# FinMind – Universal Deployment Guide + +Production-grade, one-click deployment for FinMind across all major platforms. + +## Table of Contents + +- [Architecture Overview](#architecture-overview) +- [Prerequisites](#prerequisites) +- [Environment Variables](#environment-variables) +- [Docker (Local / Production)](#docker-local--production) +- [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) +- [Kubernetes (Cloud-Agnostic)](#kubernetes-cloud-agnostic) +- [Tilt (Local K8s Development)](#tilt-local-k8s-development) +- [Health Checks & Verification](#health-checks--verification) +- [Troubleshooting](#troubleshooting) + +--- + +## Architecture Overview + +``` +┌─────────────┐ ┌──────────────┐ ┌────────────┐ +│ Frontend │────▶│ Backend │────▶│ PostgreSQL │ +│ React/Nginx │ │ Flask/Gunicorn│ │ 16 │ +│ port 80 │ │ port 8000 │────▶│────────────│ +└─────────────┘ └──────────────┘ │ Redis 7 │ + └────────────┘ +``` + +| Component | Technology | Port | +|-----------|-----------|------| +| Frontend | React 18 + Vite + Nginx | 80 | +| Backend | Flask 3 + Gunicorn | 8000 | +| Database | PostgreSQL 16 | 5432 | +| Cache | Redis 7 | 6379 | + +--- + +## Prerequisites + +- **Docker** 20.10+ and Docker Compose v2 +- **Node.js** 20+ (for frontend builds) +- **Python** 3.11+ (for backend) +- **Git** + +Platform-specific CLIs as needed (railway, heroku, flyctl, gcloud, az, aws). + +--- + +## Environment Variables + +Copy `.env.example` to `.env` and configure: + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `DATABASE_URL` | Yes | `postgresql+psycopg2://finmind:finmind@postgres:5432/finmind` | PostgreSQL connection | +| `REDIS_URL` | Yes | `redis://redis:6379/0` | Redis connection | +| `JWT_SECRET` | Yes | `change-me` | JWT signing key (**change in production**) | +| `VITE_API_URL` | Yes | `http://localhost:8000` | Backend URL for frontend | +| `POSTGRES_USER` | No | `finmind` | PostgreSQL username | +| `POSTGRES_PASSWORD` | No | `finmind` | PostgreSQL password | +| `POSTGRES_DB` | No | `finmind` | PostgreSQL database name | +| `LOG_LEVEL` | No | `INFO` | Logging level | +| `OPENAI_API_KEY` | No | - | OpenAI API key (for AI insights) | +| `GEMINI_API_KEY` | No | - | Google Gemini key | +| `TWILIO_ACCOUNT_SID` | No | - | Twilio SID (for WhatsApp reminders) | +| `TWILIO_AUTH_TOKEN` | No | - | Twilio auth token | + +--- + +## Docker (Local / Production) + +### Development (with hot-reload) + +```bash +cp .env.example .env +docker compose up --build +``` + +- Frontend: http://localhost:5173 +- Backend: http://localhost:8000 +- Health: http://localhost:8000/health + +### Production + +```bash +cp .env.example .env +# Edit .env with production values (strong JWT_SECRET, etc.) +docker compose -f docker-compose.prod.yml up -d --build +``` + +- Frontend: http://localhost:80 +- Backend: http://localhost:8000 +- Deep health: http://localhost:8000/health/ready + +--- + +## Railway + +### One-Click + +[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template) + +### Manual + +```bash +# Install Railway CLI +npm install -g @railway/cli + +# Login and initialize +railway login +railway init + +# Add PostgreSQL and Redis plugins +railway add -p postgresql +railway add -p redis + +# Set environment variables +railway variables set JWT_SECRET=$(openssl rand -hex 32) +railway variables set LOG_LEVEL=INFO + +# Deploy +railway up +``` + +Configuration: `railway.json` / `railway.toml` + +--- + +## Heroku + +### One-Click + +[![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) + +### Manual (Container Stack) + +```bash +# Login +heroku login +heroku container:login + +# Create app with addons +heroku create finmind-app --stack container +heroku addons:create heroku-postgresql:essential-0 +heroku addons:create heroku-redis:mini + +# Set config vars +heroku config:set JWT_SECRET=$(openssl rand -hex 32) +heroku config:set LOG_LEVEL=INFO + +# Deploy using heroku.yml +git push heroku main +``` + +Configuration: `heroku.yml`, `app.json`, `Procfile` + +--- + +## Render + +### One-Click + +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy) + +### Manual + +1. Connect your GitHub repository to Render. +2. Render auto-detects `render.yaml` (Blueprint). +3. Click **New Blueprint Instance**. +4. Render provisions PostgreSQL, Redis, backend, and frontend automatically. + +Configuration: `render.yaml` + +--- + +## Fly.io + +### Backend + +```bash +# Install flyctl +curl -L https://fly.io/install.sh | sh + +# Login +fly auth login + +# Launch backend +fly launch --config fly.toml --no-deploy + +# Create PostgreSQL and Redis +fly postgres create --name finmind-db +fly postgres attach finmind-db +fly redis create --name finmind-redis + +# Set secrets +fly secrets set JWT_SECRET=$(openssl rand -hex 32) +fly secrets set REDIS_URL=redis://... + +# Deploy +fly deploy +``` + +### Frontend + +```bash +fly launch --config deploy/fly-frontend.toml --no-deploy +fly deploy --config deploy/fly-frontend.toml +``` + +Configuration: `fly.toml`, `deploy/fly-frontend.toml` + +--- + +## DigitalOcean App Platform + +### One-Click + +1. Go to [DigitalOcean App Platform](https://cloud.digitalocean.com/apps). +2. Connect your GitHub repo. +3. Import `.do/app.yaml` as the App Spec. +4. Review and deploy. + +Configuration: `.do/app.yaml` + +--- + +## DigitalOcean Droplet + +### One-Click (SSH into droplet) + +```bash +export FINMIND_DOMAIN="finmind.example.com" +export JWT_SECRET=$(openssl rand -hex 32) +curl -sSL https://raw.githubusercontent.com/your-org/FinMind/main/deploy/digitalocean-droplet.sh | bash +``` + +### Manual + +```bash +git clone https://github.com/your-org/FinMind /opt/finmind +cd /opt/finmind +chmod +x deploy/digitalocean-droplet.sh +FINMIND_DOMAIN=finmind.example.com ./deploy/digitalocean-droplet.sh +``` + +The script installs Docker, Nginx, Certbot, and deploys with `docker-compose.prod.yml`. + +Configuration: `deploy/digitalocean-droplet.sh` + +--- + +## AWS ECS Fargate + +### One-Click + +```bash +export AWS_REGION=us-east-1 +chmod +x deploy/aws-deploy.sh +./deploy/aws-deploy.sh +``` + +### Manual Steps + +1. Create ECR repositories for `finmind-backend` and `finmind-frontend`. +2. Build and push Docker images. +3. Create secrets in AWS Secrets Manager. +4. Register ECS task definition from `deploy/aws-ecs-task-definition.json`. +5. Create ALB with target group. +6. Create ECS service from `deploy/aws-ecs-service.json`. + +Configuration: `deploy/aws-ecs-task-definition.json`, `deploy/aws-ecs-service.json`, `deploy/aws-deploy.sh` + +--- + +## AWS App Runner + +```bash +# Create App Runner service via console or CLI using: +# deploy/aws-apprunner.yaml + +aws apprunner create-service \ + --service-name finmind-backend \ + --source-configuration file://deploy/aws-apprunner.yaml +``` + +Configuration: `deploy/aws-apprunner.yaml` + +--- + +## GCP Cloud Run + +### One-Click + +```bash +export GCP_PROJECT=your-project-id +export GCP_REGION=us-central1 +chmod +x deploy/gcp-deploy.sh +./deploy/gcp-deploy.sh +``` + +### Manual + +```bash +# Build with Cloud Build +gcloud builds submit packages/backend/ --tag gcr.io/$PROJECT/finmind-backend + +# Deploy +gcloud run deploy finmind-backend \ + --image gcr.io/$PROJECT/finmind-backend \ + --port 8000 \ + --allow-unauthenticated +``` + +Configuration: `deploy/gcp-cloudrun.yaml`, `deploy/gcp-deploy.sh` + +--- + +## Azure Container Apps + +### One-Click + +```bash +export AZURE_RESOURCE_GROUP=finmind-rg +export AZURE_LOCATION=eastus +chmod +x deploy/azure-deploy.sh +./deploy/azure-deploy.sh +``` + +### Manual + +1. Create resource group and Container Registry. +2. Build and push images via ACR. +3. Create Container Apps environment. +4. Deploy using `deploy/azure-containerapp.yaml`. + +Configuration: `deploy/azure-containerapp.yaml`, `deploy/azure-deploy.sh` + +--- + +## Netlify (Frontend) + +### One-Click + +[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start) + +### Manual + +1. Connect your GitHub repo to Netlify. +2. Set **Base directory** to `app/`. +3. Set **Build command** to `npm ci && npm run build`. +4. Set **Publish directory** to `app/dist`. +5. Add environment variable `VITE_API_URL` pointing to your backend. + +Configuration: `netlify.toml` + +--- + +## Vercel (Frontend) + +### One-Click + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new) + +### Manual + +```bash +# Install Vercel CLI +npm install -g vercel + +# Deploy +cd app +vercel --prod +``` + +Set `VITE_API_URL` in Vercel project environment variables. + +Configuration: `vercel.json` + +--- + +## Kubernetes (Cloud-Agnostic) + +Full Helm chart with production features: autoscaling, TLS, health probes, network policies, observability. + +### Quick Start + +```bash +# Create namespace and install +helm install finmind ./k8s/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.example.com +``` + +### With Custom Values + +```bash +# Copy and edit values +cp k8s/helm/finmind/values.yaml my-values.yaml +# Edit my-values.yaml with your settings + +helm install finmind ./k8s/helm/finmind \ + --namespace finmind \ + --create-namespace \ + -f my-values.yaml +``` + +### Upgrade + +```bash +helm upgrade finmind ./k8s/helm/finmind \ + --namespace finmind \ + -f my-values.yaml +``` + +### Features + +| Feature | Status | +|---------|--------| +| Helm charts | Included | +| Ingress + TLS (cert-manager) | Included | +| HPA autoscaling (CPU/Memory) | Included | +| Secret management | Kubernetes Secrets | +| Health probes (startup/readiness/liveness) | All components | +| Network policies | PostgreSQL + Redis isolated | +| ServiceMonitor (Prometheus) | Optional (`observability.serviceMonitor.enabled=true`) | +| Init-DB job (Helm hook) | Automatic | + +### TLS Setup + +Requires [cert-manager](https://cert-manager.io/) installed in the cluster: + +```bash +# Install cert-manager +kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml + +# Create ClusterIssuer for Let's Encrypt +kubectl apply -f - < + +param( + [Parameter(Mandatory=$true, Position=0)] + [ValidateSet('docker-dev','docker-prod','railway','heroku','render','flyio', + 'digitalocean','aws','gcp','azure','k8s','tilt','verify')] + [string]$Platform, + + [Parameter(Position=1)] + [string]$Url = "http://localhost" +) + +$ErrorActionPreference = "Stop" + +function Write-Info { Write-Host "[INFO] $args" -ForegroundColor Cyan } +function Write-Success { Write-Host "[OK] $args" -ForegroundColor Green } +function Write-Warn { Write-Host "[WARN] $args" -ForegroundColor Yellow } +function Write-Err { Write-Host "[ERROR] $args" -ForegroundColor Red; exit 1 } + +function Ensure-Env { + if (-not (Test-Path .env)) { + Write-Info "Creating .env from .env.example..." + Copy-Item .env.example .env + Write-Warn "Please edit .env with production values (especially JWT_SECRET)." + } +} + +function Test-Command($cmd) { + if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) { + Write-Err "'$cmd' is not installed. Please install it first." + } +} + +# Find a bash executable (WSL, Git Bash, or MSYS2) +function Find-Bash { + if (Get-Command "bash" -ErrorAction SilentlyContinue) { return "bash" } + if (Get-Command "wsl" -ErrorAction SilentlyContinue) { return "wsl bash" } + $gitBash = "C:\Program Files\Git\bin\bash.exe" + if (Test-Path $gitBash) { return "`"$gitBash`"" } + return $null +} + +function Deploy-DockerDev { + Write-Info "Starting FinMind in development mode..." + Test-Command docker + Ensure-Env + docker compose up --build +} + +function Deploy-DockerProd { + Write-Info "Starting FinMind in production mode..." + Test-Command docker + Ensure-Env + docker compose -f docker-compose.prod.yml up -d --build + Write-Info "Waiting for services to start..." + Start-Sleep -Seconds 15 + Verify-Deployment "http://localhost" +} + +function Deploy-Railway { + Write-Info "Deploying to Railway..." + Test-Command railway + Ensure-Env + railway up + Write-Success "Deployed to Railway." +} + +function Deploy-Heroku { + Write-Info "Deploying to Heroku..." + Test-Command heroku + $app = if ($env:HEROKU_APP_NAME) { $env:HEROKU_APP_NAME } else { "finmind-app" } + heroku container:login + heroku create $app --stack container 2>$null + heroku addons:create heroku-postgresql:essential-0 -a $app 2>$null + heroku addons:create heroku-redis:mini -a $app 2>$null + $secret = -join ((1..32) | ForEach-Object { '{0:x2}' -f (Get-Random -Max 256) }) + heroku config:set JWT_SECRET=$secret -a $app + git push heroku main + Write-Success "Deployed to Heroku: https://$app.herokuapp.com" +} + +function Deploy-Render { + Write-Info "Render uses render.yaml blueprint." + Write-Info "Go to https://render.com/deploy and connect this repo." + Write-Success "Blueprint file ready: render.yaml" +} + +function Deploy-FlyIo { + Write-Info "Deploying to Fly.io..." + Test-Command fly + fly launch --config fly.toml --no-deploy --yes 2>$null + fly deploy + Write-Info "Deploying frontend..." + fly launch --config deploy/fly-frontend.toml --no-deploy --yes 2>$null + fly deploy --config deploy/fly-frontend.toml + Write-Success "Deployed to Fly.io" +} + +function Deploy-AWS { + Write-Info "Deploying to AWS ECS Fargate..." + Test-Command docker + $bashCmd = Find-Bash + if (-not $bashCmd) { Write-Err "bash is required (install WSL or Git for Windows)." } + Ensure-Env + Invoke-Expression "$bashCmd deploy/aws-deploy.sh" + Write-Success "Deployed to AWS ECS Fargate." +} + +function Deploy-GCP { + Write-Info "Deploying to GCP Cloud Run..." + $bashCmd = Find-Bash + if (-not $bashCmd) { Write-Err "bash is required (install WSL or Git for Windows)." } + Ensure-Env + Invoke-Expression "$bashCmd deploy/gcp-deploy.sh" + Write-Success "Deployed to GCP Cloud Run." +} + +function Deploy-Azure { + Write-Info "Deploying to Azure Container Apps..." + $bashCmd = Find-Bash + if (-not $bashCmd) { Write-Err "bash is required (install WSL or Git for Windows)." } + Ensure-Env + Invoke-Expression "$bashCmd deploy/azure-deploy.sh" + Write-Success "Deployed to Azure Container Apps." +} + +function Deploy-DigitalOcean { + Write-Info "Deploying to DigitalOcean Droplet..." + $bashCmd = Find-Bash + if (-not $bashCmd) { Write-Err "bash is required (install WSL or Git for Windows)." } + Ensure-Env + Invoke-Expression "$bashCmd deploy/digitalocean-droplet.sh" + Write-Success "Deployed to DigitalOcean." +} + +function Deploy-K8s { + Write-Info "Deploying to Kubernetes via Helm..." + Test-Command helm + Test-Command kubectl + $jwt = -join ((1..32) | ForEach-Object { '{0:x2}' -f (Get-Random -Max 256) }) + $pgPass = -join ((1..16) | ForEach-Object { '{0:x2}' -f (Get-Random -Max 256) }) + helm upgrade --install finmind ./k8s/helm/finmind ` + --namespace finmind ` + --create-namespace ` + --set "secrets.jwtSecret=$jwt" ` + --set "secrets.postgresPassword=$pgPass" ` + --set "secrets.databaseUrl=postgresql+psycopg2://finmind:${pgPass}@finmind-postgres:5432/finmind" ` + --wait --timeout 5m + Write-Success "FinMind deployed to Kubernetes namespace 'finmind'" + kubectl get pods -n finmind +} + +function Deploy-Tilt { + Write-Info "Starting Tilt local K8s development..." + Test-Command tilt + tilt up +} + +function Verify-Deployment($BaseUrl) { + $backend = "${BaseUrl}:8000" + $pass = 0; $fail = 0 + + Write-Host "" + Write-Info "=== FinMind Deployment Verification ===" + Write-Host "" + + # Frontend + try { + $null = Invoke-WebRequest -Uri "$BaseUrl/" -UseBasicParsing -TimeoutSec 10 + Write-Success "Frontend reachable"; $pass++ + } catch { Write-Warn "Frontend NOT reachable"; $fail++ } + + # Backend health + try { + $null = Invoke-WebRequest -Uri "$backend/health" -UseBasicParsing -TimeoutSec 10 + Write-Success "Backend health passed"; $pass++ + } catch { Write-Warn "Backend health failed"; $fail++ } + + # Deep health + try { + $resp = (Invoke-WebRequest -Uri "$backend/health/ready" -UseBasicParsing -TimeoutSec 10).Content + if ($resp -match '"status":"ok"') { + Write-Success "DB + Redis connected"; $pass++ + } else { Write-Warn "Deep health degraded: $resp"; $fail++ } + } catch { Write-Warn "Deep health check failed"; $fail++ } + + Write-Host "" + Write-Host "==========================================" + Write-Host " Results: $pass passed, $fail failed" -ForegroundColor $(if ($fail -eq 0) { "Green" } else { "Yellow" }) + Write-Host "==========================================" +} + +# ── Main dispatch ── +switch ($Platform) { + 'docker-dev' { Deploy-DockerDev } + 'docker-prod' { Deploy-DockerProd } + 'railway' { Deploy-Railway } + 'heroku' { Deploy-Heroku } + 'render' { Deploy-Render } + 'flyio' { Deploy-FlyIo } + 'digitalocean' { Deploy-DigitalOcean } + 'aws' { Deploy-AWS } + 'gcp' { Deploy-GCP } + 'azure' { Deploy-Azure } + 'k8s' { Deploy-K8s } + 'tilt' { Deploy-Tilt } + 'verify' { Verify-Deployment $Url } +} diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 00000000..031f2bc3 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +set -euo pipefail + +############################################################################### +# FinMind – Universal One-Click Deploy Script +# +# Usage: +# ./deploy.sh +# +# Platforms: +# docker-dev – Docker Compose (development, hot-reload) +# docker-prod – Docker Compose (production) +# railway – Railway +# heroku – Heroku (container stack) +# render – Render (opens dashboard) +# flyio – Fly.io +# digitalocean – DigitalOcean Droplet +# aws – AWS ECS Fargate +# gcp – GCP Cloud Run +# azure – Azure Container Apps +# k8s – Kubernetes via Helm +# tilt – Tilt local K8s dev +# verify – Verify a running deployment +############################################################################### + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +success() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } + +ensure_env() { + if [ ! -f .env ]; then + info "Creating .env from .env.example..." + cp .env.example .env + warn "Please edit .env with your production values (especially JWT_SECRET)." + fi +} + +check_cmd() { + command -v "$1" &>/dev/null || error "'$1' is not installed. Please install it first." +} + +# ── Platform deploy functions ── + +deploy_docker_dev() { + info "Starting FinMind in development mode..." + check_cmd docker + ensure_env + docker compose up --build +} + +deploy_docker_prod() { + info "Starting FinMind in production mode..." + check_cmd docker + ensure_env + docker compose -f docker-compose.prod.yml up -d --build + info "Waiting for services to start..." + sleep 15 + verify_deployment "http://localhost" +} + +deploy_railway() { + info "Deploying to Railway..." + check_cmd railway + ensure_env + railway up + success "Deployed to Railway." +} + +deploy_heroku() { + info "Deploying to Heroku..." + check_cmd heroku + local APP="${HEROKU_APP_NAME:-finmind-app}" + heroku container:login + heroku create "$APP" --stack container 2>/dev/null || true + heroku addons:create heroku-postgresql:essential-0 -a "$APP" 2>/dev/null || true + heroku addons:create heroku-redis:mini -a "$APP" 2>/dev/null || true + heroku config:set JWT_SECRET="$(openssl rand -hex 32)" -a "$APP" + git push heroku main + success "Deployed to Heroku: https://${APP}.herokuapp.com" +} + +deploy_render() { + info "Render uses render.yaml blueprint." + info "Go to https://render.com/deploy and connect this repo." + info "Render will auto-detect render.yaml and provision all services." + success "Blueprint file ready: render.yaml" +} + +deploy_flyio() { + info "Deploying to Fly.io..." + check_cmd fly + fly launch --config fly.toml --no-deploy --yes 2>/dev/null || true + fly postgres create --name finmind-db 2>/dev/null || warn "Postgres may already exist" + fly postgres attach finmind-db 2>/dev/null || warn "Postgres may already be attached" + fly secrets set JWT_SECRET="$(openssl rand -hex 32)" 2>/dev/null || true + fly deploy + info "Deploying frontend..." + fly launch --config deploy/fly-frontend.toml --no-deploy --yes 2>/dev/null || true + fly deploy --config deploy/fly-frontend.toml + success "Deployed to Fly.io" +} + +deploy_digitalocean() { + info "Deploying to DigitalOcean Droplet..." + chmod +x deploy/digitalocean-droplet.sh + exec deploy/digitalocean-droplet.sh +} + +deploy_aws() { + info "Deploying to AWS ECS Fargate..." + chmod +x deploy/aws-deploy.sh + exec deploy/aws-deploy.sh +} + +deploy_gcp() { + info "Deploying to GCP Cloud Run..." + chmod +x deploy/gcp-deploy.sh + exec deploy/gcp-deploy.sh +} + +deploy_azure() { + info "Deploying to Azure Container Apps..." + chmod +x deploy/azure-deploy.sh + exec deploy/azure-deploy.sh +} + +deploy_k8s() { + info "Deploying to Kubernetes via Helm..." + check_cmd helm + check_cmd kubectl + + local JWT="${JWT_SECRET:-$(openssl rand -hex 32)}" + local PG_PASS="${POSTGRES_PASSWORD:-$(openssl rand -hex 16)}" + + helm upgrade --install finmind ./k8s/helm/finmind \ + --namespace finmind \ + --create-namespace \ + --set secrets.jwtSecret="$JWT" \ + --set secrets.postgresPassword="$PG_PASS" \ + --set "secrets.databaseUrl=postgresql+psycopg2://finmind:${PG_PASS}@finmind-postgres:5432/finmind" \ + --wait \ + --timeout 5m + + success "FinMind deployed to Kubernetes namespace 'finmind'" + kubectl get pods -n finmind +} + +deploy_tilt() { + info "Starting Tilt local K8s development..." + check_cmd tilt + check_cmd kubectl + tilt up +} + +# ── Verification ── + +verify_deployment() { + local BASE_URL="${1:-http://localhost}" + local BACKEND="${BASE_URL}:8000" + local PASS=0 + local FAIL=0 + + echo "" + info "=== FinMind Deployment Verification ===" + echo "" + + # Frontend + if curl -sf "${BASE_URL}/" >/dev/null 2>&1; then + success "Frontend reachable at ${BASE_URL}/" + PASS=$((PASS+1)) + else + warn "Frontend NOT reachable at ${BASE_URL}/" + FAIL=$((FAIL+1)) + fi + + # Backend health + if curl -sf "${BACKEND}/health" >/dev/null 2>&1; then + success "Backend health check passed" + PASS=$((PASS+1)) + else + warn "Backend health check failed at ${BACKEND}/health" + FAIL=$((FAIL+1)) + fi + + # Deep health (DB + Redis) + local READY + READY=$(curl -sf "${BACKEND}/health/ready" 2>/dev/null || echo "") + if echo "$READY" | grep -q '"status":"ok"'; then + success "DB + Redis connected (deep health passed)" + PASS=$((PASS+1)) + else + warn "Deep health check failed: ${READY}" + FAIL=$((FAIL+1)) + fi + + # Auth flow + local REG_RESP + REG_RESP=$(curl -sf -X POST "${BACKEND}/auth/register" \ + -H "Content-Type: application/json" \ + -d '{"email":"verify-test@example.com","password":"TestPass123!"}' 2>/dev/null || echo "") + local LOGIN_RESP + LOGIN_RESP=$(curl -sf -X POST "${BACKEND}/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"verify-test@example.com","password":"TestPass123!"}' 2>/dev/null || echo "") + if echo "$LOGIN_RESP" | grep -q "access_token"; then + success "Auth flow working (register + login)" + PASS=$((PASS+1)) + else + warn "Auth flow verification failed" + FAIL=$((FAIL+1)) + fi + + # Core modules (with token) + local TOKEN + TOKEN=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || echo "") + if [ -n "$TOKEN" ]; then + for endpoint in expenses/ bills/ reminders/ categories/ dashboard/summary insights/; do + local RESP_CODE + RESP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer ${TOKEN}" \ + "${BACKEND}/${endpoint}" 2>/dev/null || echo "000") + if [ "$RESP_CODE" -ge 200 ] && [ "$RESP_CODE" -lt 500 ]; then + success "/${endpoint} reachable (HTTP ${RESP_CODE})" + PASS=$((PASS+1)) + else + warn "/${endpoint} failed (HTTP ${RESP_CODE})" + FAIL=$((FAIL+1)) + fi + done + else + warn "Could not obtain auth token – skipping module checks" + FAIL=$((FAIL+6)) + fi + + echo "" + echo "============================================" + echo -e " Results: ${GREEN}${PASS} passed${NC}, ${RED}${FAIL} failed${NC}" + echo "============================================" + + [ "$FAIL" -eq 0 ] && return 0 || return 1 +} + +# ── Main ── + +usage() { + echo "Usage: $0 " + echo "" + echo "Platforms:" + echo " docker-dev Docker Compose (development)" + echo " docker-prod Docker Compose (production)" + echo " railway Railway" + echo " heroku Heroku" + echo " render Render" + echo " flyio Fly.io" + echo " digitalocean DigitalOcean Droplet" + echo " aws AWS ECS Fargate" + echo " gcp GCP Cloud Run" + echo " azure Azure Container Apps" + echo " k8s Kubernetes (Helm)" + echo " tilt Tilt local K8s" + echo " verify [URL] Verify deployment" + exit 1 +} + +PLATFORM="${1:-}" +[ -z "$PLATFORM" ] && usage + +case "$PLATFORM" in + docker-dev) deploy_docker_dev ;; + docker-prod) deploy_docker_prod ;; + railway) deploy_railway ;; + heroku) deploy_heroku ;; + render) deploy_render ;; + flyio) deploy_flyio ;; + digitalocean) deploy_digitalocean ;; + aws) deploy_aws ;; + gcp) deploy_gcp ;; + azure) deploy_azure ;; + k8s) deploy_k8s ;; + tilt) deploy_tilt ;; + verify) verify_deployment "${2:-http://localhost}" ;; + *) error "Unknown platform: $PLATFORM" ;; +esac diff --git a/deploy/aws-apprunner.yaml b/deploy/aws-apprunner.yaml new file mode 100644 index 00000000..6c2755a9 --- /dev/null +++ b/deploy/aws-apprunner.yaml @@ -0,0 +1,23 @@ +version: 1.0 +runtime: python311 +build: + commands: + build: + - pip install -r packages/backend/requirements.txt +run: + command: >- + sh -c "cd packages/backend && + python -m flask --app wsgi:app init-db && + gunicorn -w 2 -k gthread --timeout 120 -b 0.0.0.0:8000 wsgi:app" + network: + port: 8000 + env: + - name: LOG_LEVEL + value: INFO + secrets: + - name: DATABASE_URL + value-from: "arn:aws:secretsmanager:REGION:ACCOUNT_ID:secret:finmind/database-url" + - name: REDIS_URL + value-from: "arn:aws:secretsmanager:REGION:ACCOUNT_ID:secret:finmind/redis-url" + - name: JWT_SECRET + value-from: "arn:aws:secretsmanager:REGION:ACCOUNT_ID:secret:finmind/jwt-secret" diff --git a/deploy/aws-deploy.sh b/deploy/aws-deploy.sh new file mode 100644 index 00000000..3542fdce --- /dev/null +++ b/deploy/aws-deploy.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +set -euo pipefail + +############################################################################### +# FinMind – AWS ECS Fargate one-click deploy (end-to-end) +# +# Deploys both backend AND frontend behind an Application Load Balancer. +# The script handles: ECR repos, images, ECS cluster, ALB, target groups, +# path-based routing, security groups, IAM roles, and service creation. +# +# Prerequisites: +# - AWS CLI v2 configured (aws configure) +# - Docker installed +# +# Usage: +# export AWS_REGION=us-east-1 +# export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +# ./deploy/aws-deploy.sh +############################################################################### + +REGION="${AWS_REGION:-us-east-1}" +ACCOUNT_ID="${AWS_ACCOUNT_ID:-$(aws sts get-caller-identity --query Account --output text)}" +CLUSTER_NAME="finmind-cluster" +REPO_BACKEND="finmind-backend" +REPO_FRONTEND="finmind-frontend" +EXECUTION_ROLE="ecsTaskExecutionRole" +SG_NAME="finmind-ecs-sg" + +echo "==> FinMind AWS ECS Fargate Deploy" +echo " Region : ${REGION}" +echo " Account : ${ACCOUNT_ID}" + +# ── 1. Create ECR repositories ── +echo "==> Creating ECR repositories..." +for repo in "$REPO_BACKEND" "$REPO_FRONTEND"; do + aws ecr describe-repositories --repository-names "$repo" --region "$REGION" 2>/dev/null || \ + aws ecr create-repository --repository-name "$repo" --region "$REGION" --output text +done + +# ── 2. Authenticate Docker to ECR ── +echo "==> Authenticating Docker to ECR..." +aws ecr get-login-password --region "$REGION" | \ + docker login --username AWS --password-stdin "${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com" + +# ── 3. Create ECS cluster ── +echo "==> Creating ECS cluster..." +aws ecs describe-clusters --clusters "$CLUSTER_NAME" --region "$REGION" 2>/dev/null | \ + grep -q "ACTIVE" || \ + aws ecs create-cluster --cluster-name "$CLUSTER_NAME" --region "$REGION" --output text + +# ── 4. Create Secrets Manager secrets (if not exist) ── +echo "==> Ensuring secrets exist..." +for secret in "finmind/database-url" "finmind/redis-url" "finmind/jwt-secret"; do + aws secretsmanager describe-secret --secret-id "$secret" --region "$REGION" 2>/dev/null || \ + aws secretsmanager create-secret --name "$secret" --secret-string "CHANGE_ME" --region "$REGION" --output text +done + +# ── 5. Create CloudWatch log group ── +aws logs create-log-group --log-group-name "/ecs/finmind" --region "$REGION" 2>/dev/null || true + +# ── 6. Ensure IAM execution role exists ── +echo "==> Ensuring ECS execution role..." +if ! aws iam get-role --role-name "$EXECUTION_ROLE" 2>/dev/null; then + aws iam create-role --role-name "$EXECUTION_ROLE" \ + --assume-role-policy-document '{ + "Version":"2012-10-17", + "Statement":[{ + "Effect":"Allow", + "Principal":{"Service":"ecs-tasks.amazonaws.com"}, + "Action":"sts:AssumeRole" + }] + }' --output text + aws iam attach-role-policy --role-name "$EXECUTION_ROLE" \ + --policy-arn "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" + aws iam attach-role-policy --role-name "$EXECUTION_ROLE" \ + --policy-arn "arn:aws:iam::aws:policy/SecretsManagerReadWrite" + echo " Waiting for role propagation..." + sleep 10 +fi + +# ── 7. Networking: detect VPC, subnets, create security group ── +echo "==> Setting up networking..." +VPC_ID=$(aws ec2 describe-vpcs --filters "Name=isDefault,Values=true" \ + --query "Vpcs[0].VpcId" --output text --region "$REGION") +if [ "$VPC_ID" = "None" ] || [ -z "$VPC_ID" ]; then + echo "ERROR: No default VPC found. Create one with: aws ec2 create-default-vpc" + exit 1 +fi +echo " VPC: ${VPC_ID}" + +# Get public subnets (fall back to all subnets) +SUBNET_IDS=$(aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" \ + --query "Subnets[?MapPublicIpOnLaunch==\`true\`].SubnetId" --output text --region "$REGION") +if [ -z "$SUBNET_IDS" ]; then + SUBNET_IDS=$(aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" \ + --query "Subnets[*].SubnetId" --output text --region "$REGION") +fi +SUBNET_CSV=$(echo "$SUBNET_IDS" | tr '\t' ',' | tr ' ' ',') +echo " Subnets: ${SUBNET_CSV}" + +# Create or find security group +SG_ID=$(aws ec2 describe-security-groups \ + --filters "Name=group-name,Values=$SG_NAME" "Name=vpc-id,Values=$VPC_ID" \ + --query "SecurityGroups[0].GroupId" --output text --region "$REGION" 2>/dev/null || echo "None") +if [ "$SG_ID" = "None" ] || [ -z "$SG_ID" ]; then + SG_ID=$(aws ec2 create-security-group --group-name "$SG_NAME" \ + --description "FinMind ECS security group" \ + --vpc-id "$VPC_ID" --region "$REGION" --query 'GroupId' --output text) +fi +echo " Security Group: ${SG_ID}" + +# Allow inbound HTTP (idempotent) +aws ec2 authorize-security-group-ingress --group-id "$SG_ID" --protocol tcp --port 80 --cidr 0.0.0.0/0 --region "$REGION" 2>/dev/null || true +aws ec2 authorize-security-group-ingress --group-id "$SG_ID" --protocol tcp --port 8000 --cidr 0.0.0.0/0 --region "$REGION" 2>/dev/null || true + +# ── 8. Create Application Load Balancer ── +echo "==> Creating Application Load Balancer..." +ALB_ARN=$(aws elbv2 describe-load-balancers --names finmind-alb --region "$REGION" \ + --query "LoadBalancers[0].LoadBalancerArn" --output text 2>/dev/null || echo "None") +if [ "$ALB_ARN" = "None" ] || [ -z "$ALB_ARN" ]; then + ALB_ARN=$(aws elbv2 create-load-balancer --name finmind-alb \ + --subnets $SUBNET_IDS --security-groups "$SG_ID" \ + --region "$REGION" --query 'LoadBalancers[0].LoadBalancerArn' --output text) + echo " Waiting for ALB to become active..." + aws elbv2 wait load-balancer-available --load-balancer-arns "$ALB_ARN" --region "$REGION" +fi +ALB_DNS=$(aws elbv2 describe-load-balancers --load-balancer-arns "$ALB_ARN" \ + --query 'LoadBalancers[0].DNSName' --output text --region "$REGION") +echo " ALB DNS: ${ALB_DNS}" + +# ── 9. Build & push images ── +echo "==> Building backend..." +docker build -t "${REPO_BACKEND}:latest" -f packages/backend/Dockerfile packages/backend/ +docker tag "${REPO_BACKEND}:latest" "${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/${REPO_BACKEND}:latest" +docker push "${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/${REPO_BACKEND}:latest" + +echo "==> Building frontend (VITE_API_URL=http://${ALB_DNS})..." +docker build --build-arg "VITE_API_URL=http://${ALB_DNS}" \ + -t "${REPO_FRONTEND}:latest" -f app/Dockerfile app/ +docker tag "${REPO_FRONTEND}:latest" "${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/${REPO_FRONTEND}:latest" +docker push "${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/${REPO_FRONTEND}:latest" + +# ── 10. Create target groups ── +echo "==> Creating target groups..." +BACKEND_TG_ARN=$(aws elbv2 describe-target-groups --names finmind-backend-tg --region "$REGION" \ + --query "TargetGroups[0].TargetGroupArn" --output text 2>/dev/null || echo "None") +if [ "$BACKEND_TG_ARN" = "None" ] || [ -z "$BACKEND_TG_ARN" ]; then + BACKEND_TG_ARN=$(aws elbv2 create-target-group --name finmind-backend-tg \ + --protocol HTTP --port 8000 --vpc-id "$VPC_ID" --target-type ip \ + --health-check-path /health --health-check-interval-seconds 30 \ + --healthy-threshold-count 2 --unhealthy-threshold-count 3 \ + --region "$REGION" --query 'TargetGroups[0].TargetGroupArn' --output text) +fi + +FRONTEND_TG_ARN=$(aws elbv2 describe-target-groups --names finmind-frontend-tg --region "$REGION" \ + --query "TargetGroups[0].TargetGroupArn" --output text 2>/dev/null || echo "None") +if [ "$FRONTEND_TG_ARN" = "None" ] || [ -z "$FRONTEND_TG_ARN" ]; then + FRONTEND_TG_ARN=$(aws elbv2 create-target-group --name finmind-frontend-tg \ + --protocol HTTP --port 80 --vpc-id "$VPC_ID" --target-type ip \ + --health-check-path / --health-check-interval-seconds 30 \ + --healthy-threshold-count 2 --unhealthy-threshold-count 3 \ + --region "$REGION" --query 'TargetGroups[0].TargetGroupArn' --output text) +fi + +# ── 11. Configure ALB listener + path-based routing ── +echo "==> Configuring ALB routing..." +LISTENER_ARN=$(aws elbv2 describe-listeners --load-balancer-arn "$ALB_ARN" --region "$REGION" \ + --query "Listeners[?Port==\`80\`].ListenerArn | [0]" --output text 2>/dev/null || echo "None") +if [ "$LISTENER_ARN" = "None" ] || [ -z "$LISTENER_ARN" ]; then + LISTENER_ARN=$(aws elbv2 create-listener --load-balancer-arn "$ALB_ARN" \ + --protocol HTTP --port 80 \ + --default-actions "Type=forward,TargetGroupArn=$FRONTEND_TG_ARN" \ + --region "$REGION" --query 'Listeners[0].ListenerArn' --output text) +fi + +# Route backend API paths to backend target group +aws elbv2 create-rule --listener-arn "$LISTENER_ARN" --priority 10 \ + --conditions '[{"Field":"path-pattern","Values":["/health","/health/*","/auth/*","/expenses/*","/bills/*","/reminders/*","/insights/*","/categories/*","/dashboard/*"]}]' \ + --actions '[{"Type":"forward","TargetGroupArn":"'"$BACKEND_TG_ARN"'"}]' \ + --region "$REGION" 2>/dev/null || true + +# ── 12. Register task definition ── +echo "==> Registering task definition..." +TASK_DEF=$(cat deploy/aws-ecs-task-definition.json | \ + sed "s/ACCOUNT_ID/${ACCOUNT_ID}/g" | \ + sed "s/REGION/${REGION}/g") +echo "$TASK_DEF" | aws ecs register-task-definition --cli-input-json file:///dev/stdin --region "$REGION" --output text + +# ── 13. Create or update ECS service ── +echo "==> Creating ECS service..." +EXISTING_SERVICE=$(aws ecs describe-services --cluster "$CLUSTER_NAME" --services finmind --region "$REGION" \ + --query "services[?status=='ACTIVE'].serviceName | [0]" --output text 2>/dev/null || echo "None") + +if [ "$EXISTING_SERVICE" != "None" ] && [ -n "$EXISTING_SERVICE" ]; then + echo " Updating existing service..." + aws ecs update-service --cluster "$CLUSTER_NAME" --service finmind \ + --task-definition finmind --force-new-deployment --region "$REGION" --output text +else + aws ecs create-service \ + --cluster "$CLUSTER_NAME" \ + --service-name finmind \ + --task-definition finmind \ + --desired-count 1 \ + --launch-type FARGATE \ + --network-configuration "awsvpcConfiguration={subnets=[$SUBNET_CSV],securityGroups=[$SG_ID],assignPublicIp=ENABLED}" \ + --load-balancers "targetGroupArn=$BACKEND_TG_ARN,containerName=finmind-backend,containerPort=8000" "targetGroupArn=$FRONTEND_TG_ARN,containerName=finmind-frontend,containerPort=80" \ + --health-check-grace-period-seconds 120 \ + --region "$REGION" --output text +fi + +# ── 14. Wait for service to stabilize ── +echo "==> Waiting for service to stabilize (this may take 3-5 minutes)..." +aws ecs wait services-stable --cluster "$CLUSTER_NAME" --services finmind --region "$REGION" || true + +echo "" +echo "============================================" +echo " FinMind deployed to AWS ECS Fargate!" +echo "" +echo " ALB URL : http://${ALB_DNS}" +echo " Backend : http://${ALB_DNS}/health" +echo " Frontend : http://${ALB_DNS}/" +echo "" +echo " NOTE: Update secrets in AWS Secrets Manager" +echo " with real DATABASE_URL, REDIS_URL, JWT_SECRET" +echo "============================================" diff --git a/deploy/aws-ecs-service.json b/deploy/aws-ecs-service.json new file mode 100644 index 00000000..9af0efe9 --- /dev/null +++ b/deploy/aws-ecs-service.json @@ -0,0 +1,35 @@ +{ + "serviceName": "finmind", + "cluster": "finmind-cluster", + "taskDefinition": "finmind", + "desiredCount": 1, + "launchType": "FARGATE", + "deploymentConfiguration": { + "maximumPercent": 200, + "minimumHealthyPercent": 100, + "deploymentCircuitBreaker": { + "enable": true, + "rollback": true + } + }, + "networkConfiguration": { + "awsvpcConfiguration": { + "subnets": ["SUBNET_IDS"], + "securityGroups": ["SG_ID"], + "assignPublicIp": "ENABLED" + } + }, + "loadBalancers": [ + { + "targetGroupArn": "BACKEND_TG_ARN", + "containerName": "finmind-backend", + "containerPort": 8000 + }, + { + "targetGroupArn": "FRONTEND_TG_ARN", + "containerName": "finmind-frontend", + "containerPort": 80 + } + ], + "healthCheckGracePeriodSeconds": 120 +} diff --git a/deploy/aws-ecs-task-definition.json b/deploy/aws-ecs-task-definition.json new file mode 100644 index 00000000..4d63d9e8 --- /dev/null +++ b/deploy/aws-ecs-task-definition.json @@ -0,0 +1,85 @@ +{ + "family": "finmind", + "networkMode": "awsvpc", + "requiresCompatibilities": ["FARGATE"], + "cpu": "1024", + "memory": "2048", + "executionRoleArn": "arn:aws:iam::ACCOUNT_ID:role/ecsTaskExecutionRole", + "taskRoleArn": "arn:aws:iam::ACCOUNT_ID:role/ecsTaskExecutionRole", + "containerDefinitions": [ + { + "name": "finmind-backend", + "image": "ACCOUNT_ID.dkr.ecr.REGION.amazonaws.com/finmind-backend:latest", + "essential": true, + "portMappings": [ + { + "containerPort": 8000, + "protocol": "tcp" + } + ], + "command": [ + "sh", "-c", + "python -m flask --app wsgi:app init-db && gunicorn -w 2 -k gthread --timeout 120 -b 0.0.0.0:8000 wsgi:app" + ], + "healthCheck": { + "command": ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\" || exit 1"], + "interval": 30, + "timeout": 5, + "retries": 3, + "startPeriod": 60 + }, + "environment": [ + { "name": "LOG_LEVEL", "value": "INFO" }, + { "name": "PORT", "value": "8000" } + ], + "secrets": [ + { + "name": "DATABASE_URL", + "valueFrom": "arn:aws:secretsmanager:REGION:ACCOUNT_ID:secret:finmind/database-url" + }, + { + "name": "REDIS_URL", + "valueFrom": "arn:aws:secretsmanager:REGION:ACCOUNT_ID:secret:finmind/redis-url" + }, + { + "name": "JWT_SECRET", + "valueFrom": "arn:aws:secretsmanager:REGION:ACCOUNT_ID:secret:finmind/jwt-secret" + } + ], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/finmind", + "awslogs-region": "REGION", + "awslogs-stream-prefix": "backend" + } + } + }, + { + "name": "finmind-frontend", + "image": "ACCOUNT_ID.dkr.ecr.REGION.amazonaws.com/finmind-frontend:latest", + "essential": true, + "portMappings": [ + { + "containerPort": 80, + "protocol": "tcp" + } + ], + "healthCheck": { + "command": ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"], + "interval": 30, + "timeout": 5, + "retries": 3, + "startPeriod": 10 + }, + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/finmind", + "awslogs-region": "REGION", + "awslogs-stream-prefix": "frontend" + } + } + } + ] +} diff --git a/deploy/azure-containerapp.yaml b/deploy/azure-containerapp.yaml new file mode 100644 index 00000000..ddd74ed1 --- /dev/null +++ b/deploy/azure-containerapp.yaml @@ -0,0 +1,72 @@ +location: eastus +type: Microsoft.App/containerApps +properties: + managedEnvironmentId: /subscriptions/SUBSCRIPTION_ID/resourceGroups/finmind-rg/providers/Microsoft.App/managedEnvironments/finmind-env + configuration: + activeRevisionsMode: Single + ingress: + external: true + targetPort: 8000 + transport: http + traffic: + - latestRevision: true + weight: 100 + secrets: + - name: database-url + value: "postgresql+psycopg2://finmind:PASSWORD@HOST:5432/finmind" + - name: redis-url + value: "redis://HOST:6380" + - name: jwt-secret + value: "CHANGE_ME_IN_PRODUCTION" + template: + containers: + - name: finmind-backend + image: finmindregistry.azurecr.io/finmind-backend:latest + resources: + cpu: 0.5 + memory: 1Gi + command: + - sh + - -c + - >- + python -m flask --app wsgi:app init-db && + gunicorn -w 2 -k gthread --timeout 120 -b 0.0.0.0:8000 wsgi:app + env: + - name: DATABASE_URL + secretRef: database-url + - name: REDIS_URL + secretRef: redis-url + - name: JWT_SECRET + secretRef: jwt-secret + - name: LOG_LEVEL + value: INFO + - name: PORT + value: "8000" + probes: + - type: Startup + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 5 + failureThreshold: 12 + - type: Liveness + httpGet: + path: /health + port: 8000 + periodSeconds: 30 + failureThreshold: 3 + - type: Readiness + httpGet: + path: /health/ready + port: 8000 + periodSeconds: 10 + failureThreshold: 3 + scale: + minReplicas: 1 + maxReplicas: 10 + rules: + - name: http-scaling + http: + metadata: + concurrentRequests: "50" diff --git a/deploy/azure-deploy.sh b/deploy/azure-deploy.sh new file mode 100644 index 00000000..f52eee53 --- /dev/null +++ b/deploy/azure-deploy.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +############################################################################### +# FinMind – Azure Container Apps one-click deploy +# +# Prerequisites: +# - Azure CLI authenticated (az login) +# - Subscription selected (az account set -s SUBSCRIPTION_ID) +# +# Usage: +# export AZURE_RESOURCE_GROUP=finmind-rg +# export AZURE_LOCATION=eastus +# ./deploy/azure-deploy.sh +############################################################################### + +RG="${AZURE_RESOURCE_GROUP:-finmind-rg}" +LOCATION="${AZURE_LOCATION:-eastus}" +ACR_NAME="finmindregistry" +ENV_NAME="finmind-env" + +echo "==> FinMind Azure Container Apps Deploy" +echo " Resource Group: ${RG}" +echo " Location : ${LOCATION}" + +# ── 1. Create resource group ── +echo "==> Creating resource group..." +az group create --name "$RG" --location "$LOCATION" -o none + +# ── 2. Create Container Registry ── +echo "==> Creating ACR..." +az acr create --resource-group "$RG" --name "$ACR_NAME" --sku Basic --admin-enabled true -o none +ACR_SERVER="${ACR_NAME}.azurecr.io" +ACR_PASS=$(az acr credential show --name "$ACR_NAME" --query "passwords[0].value" -o tsv) + +# ── 3. Build & push images ── +echo "==> Building backend image..." +az acr build --registry "$ACR_NAME" --image finmind-backend:latest --file packages/backend/Dockerfile packages/backend/ + +echo "==> Building frontend image..." +az acr build --registry "$ACR_NAME" --image finmind-frontend:latest --file app/Dockerfile app/ + +# ── 4. Create Container Apps environment ── +echo "==> Creating Container Apps environment..." +az containerapp env create \ + --name "$ENV_NAME" \ + --resource-group "$RG" \ + --location "$LOCATION" \ + -o none + +# ── 5. Deploy backend ── +echo "==> Deploying backend..." +az containerapp create \ + --name finmind-backend \ + --resource-group "$RG" \ + --environment "$ENV_NAME" \ + --image "${ACR_SERVER}/finmind-backend:latest" \ + --registry-server "$ACR_SERVER" \ + --registry-username "$ACR_NAME" \ + --registry-password "$ACR_PASS" \ + --target-port 8000 \ + --ingress external \ + --min-replicas 1 \ + --max-replicas 10 \ + --cpu 0.5 \ + --memory 1.0Gi \ + --env-vars "LOG_LEVEL=INFO" "PORT=8000" \ + --secrets "jwt-secret=CHANGE_ME" \ + --command "sh" "-c" "python -m flask --app wsgi:app init-db && gunicorn -w 2 -k gthread --timeout 120 -b 0.0.0.0:8000 wsgi:app" \ + -o none + +BACKEND_FQDN=$(az containerapp show --name finmind-backend --resource-group "$RG" --query "properties.configuration.ingress.fqdn" -o tsv) + +# ── 6. Deploy frontend ── +echo "==> Deploying frontend..." +az containerapp create \ + --name finmind-frontend \ + --resource-group "$RG" \ + --environment "$ENV_NAME" \ + --image "${ACR_SERVER}/finmind-frontend:latest" \ + --registry-server "$ACR_SERVER" \ + --registry-username "$ACR_NAME" \ + --registry-password "$ACR_PASS" \ + --target-port 80 \ + --ingress external \ + --min-replicas 1 \ + --max-replicas 5 \ + --cpu 0.25 \ + --memory 0.5Gi \ + -o none + +FRONTEND_FQDN=$(az containerapp show --name finmind-frontend --resource-group "$RG" --query "properties.configuration.ingress.fqdn" -o tsv) + +echo "" +echo "============================================" +echo " FinMind deployed to Azure Container Apps!" +echo " Backend : https://${BACKEND_FQDN}/health" +echo " Frontend: https://${FRONTEND_FQDN}" +echo "" +echo " IMPORTANT: Set DATABASE_URL, REDIS_URL," +echo " JWT_SECRET as Container App secrets." +echo "============================================" diff --git a/deploy/digitalocean-droplet.sh b/deploy/digitalocean-droplet.sh new file mode 100644 index 00000000..a42495a2 --- /dev/null +++ b/deploy/digitalocean-droplet.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail + +############################################################################### +# FinMind – DigitalOcean Droplet one-click deploy +# +# Prerequisites: Ubuntu 22.04+ droplet with SSH access. +# Usage: +# export FINMIND_DOMAIN="finmind.example.com" +# export JWT_SECRET="$(openssl rand -hex 32)" +# curl -sSL https://raw.githubusercontent.com/your-org/FinMind/main/deploy/digitalocean-droplet.sh | bash +# +# Or clone + run: +# git clone https://github.com/your-org/FinMind && cd FinMind +# chmod +x deploy/digitalocean-droplet.sh +# FINMIND_DOMAIN=finmind.example.com JWT_SECRET=mysecret ./deploy/digitalocean-droplet.sh +############################################################################### + +DOMAIN="${FINMIND_DOMAIN:-localhost}" +JWT="${JWT_SECRET:-$(openssl rand -hex 32)}" +PG_PASS="${POSTGRES_PASSWORD:-$(openssl rand -hex 16)}" + +echo "==> FinMind Droplet Deploy" +echo " Domain : ${DOMAIN}" +echo " JWT : ${JWT:0:8}..." + +# ── 1. System packages ── +echo "==> Installing system packages..." +apt-get update -qq +apt-get install -y -qq docker.io docker-compose-plugin nginx certbot python3-certbot-nginx git + +systemctl enable --now docker + +# ── 2. Clone / update repo ── +APP_DIR="/opt/finmind" +if [ -d "$APP_DIR/.git" ]; then + echo "==> Updating existing install..." + cd "$APP_DIR" && git pull --ff-only +else + echo "==> Cloning FinMind..." + git clone https://github.com/your-org/FinMind "$APP_DIR" + cd "$APP_DIR" +fi + +# ── 3. Write .env ── +cat > "$APP_DIR/.env" < Building and starting services..." +docker compose -f docker-compose.prod.yml up -d --build + +# ── 5. Configure Nginx reverse proxy ── +echo "==> Configuring Nginx..." +cat > /etc/nginx/sites-available/finmind < Obtaining SSL certificate..." + certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos --register-unsafely-without-email || true +fi + +# ── 7. Verify ── +echo "==> Waiting for backend to start..." +sleep 15 + +if curl -sf http://127.0.0.1:8000/health > /dev/null; then + echo "==> Backend health check PASSED" +else + echo "==> WARNING: Backend health check failed, checking logs..." + docker compose -f docker-compose.prod.yml logs --tail=30 backend +fi + +echo "" +echo "============================================" +echo " FinMind deployed successfully!" +echo " Backend : https://${DOMAIN}/health" +echo " Frontend: https://${DOMAIN}/" +echo "============================================" diff --git a/deploy/fly-frontend.toml b/deploy/fly-frontend.toml new file mode 100644 index 00000000..a8cab020 --- /dev/null +++ b/deploy/fly-frontend.toml @@ -0,0 +1,30 @@ +app = "finmind-frontend" +primary_region = "iad" +kill_signal = "SIGINT" +kill_timeout = "5s" + +[build] + dockerfile = "app/Dockerfile" + + [build.args] + VITE_API_URL = "https://finmind-backend.fly.dev" + +[http_service] + internal_port = 80 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 1 + processes = ["app"] + +[[http_service.checks]] + grace_period = "10s" + interval = "30s" + method = "GET" + path = "/" + timeout = "5s" + +[[vm]] + size = "shared-cpu-1x" + memory = "256mb" + cpus = 1 diff --git a/deploy/gcp-cloudrun.yaml b/deploy/gcp-cloudrun.yaml new file mode 100644 index 00000000..640bb3b2 --- /dev/null +++ b/deploy/gcp-cloudrun.yaml @@ -0,0 +1,62 @@ +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: finmind-backend + labels: + cloud.googleapis.com/location: us-central1 + 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" + 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: PORT + value: "8000" + - name: LOG_LEVEL + value: INFO + - 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: 12 + livenessProbe: + httpGet: + path: /health + port: 8000 + periodSeconds: 15 + failureThreshold: 3 diff --git a/deploy/gcp-deploy.sh b/deploy/gcp-deploy.sh new file mode 100644 index 00000000..be953071 --- /dev/null +++ b/deploy/gcp-deploy.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +############################################################################### +# FinMind – GCP Cloud Run one-click deploy +# +# Prerequisites: +# - gcloud CLI authenticated (gcloud auth login) +# - Project set (gcloud config set project PROJECT_ID) +# - Cloud SQL (PostgreSQL) + Memorystore (Redis) provisioned +# +# Usage: +# export GCP_PROJECT=your-project-id +# export GCP_REGION=us-central1 +# ./deploy/gcp-deploy.sh +############################################################################### + +PROJECT="${GCP_PROJECT:-$(gcloud config get-value project)}" +REGION="${GCP_REGION:-us-central1}" + +echo "==> FinMind GCP Cloud Run Deploy" +echo " Project: ${PROJECT}" +echo " Region : ${REGION}" + +# ── 1. Enable required APIs ── +echo "==> Enabling APIs..." +gcloud services enable \ + run.googleapis.com \ + containerregistry.googleapis.com \ + cloudbuild.googleapis.com \ + secretmanager.googleapis.com \ + --project="$PROJECT" + +# ── 2. Build & push backend image ── +echo "==> Building backend with Cloud Build..." +gcloud builds submit packages/backend/ \ + --tag "gcr.io/${PROJECT}/finmind-backend:latest" \ + --project="$PROJECT" + +# ── 3. Create secrets (if not exist) ── +echo "==> Ensuring secrets exist..." +for secret in finmind-database-url finmind-redis-url finmind-jwt-secret; do + gcloud secrets describe "$secret" --project="$PROJECT" 2>/dev/null || \ + echo -n "CHANGE_ME" | gcloud secrets create "$secret" --data-file=- --project="$PROJECT" +done + +# ── 4. Deploy backend to Cloud Run ── +echo "==> Deploying backend..." +gcloud run deploy finmind-backend \ + --image "gcr.io/${PROJECT}/finmind-backend:latest" \ + --platform managed \ + --region "$REGION" \ + --port 8000 \ + --memory 512Mi \ + --cpu 1 \ + --min-instances 1 \ + --max-instances 10 \ + --set-env-vars "LOG_LEVEL=INFO,PORT=8000" \ + --set-secrets "DATABASE_URL=finmind-database-url:latest,REDIS_URL=finmind-redis-url:latest,JWT_SECRET=finmind-jwt-secret:latest" \ + --allow-unauthenticated \ + --project="$PROJECT" + +BACKEND_URL=$(gcloud run services describe finmind-backend --region="$REGION" --project="$PROJECT" --format="value(status.url)") +echo " Backend URL: ${BACKEND_URL}" + +# ── 5. Build frontend with correct VITE_API_URL baked in at build time ── +# Vite embeds VITE_API_URL at build time, so we must rebuild after knowing +# the backend URL. We use Cloud Build with a custom config to pass build args. +echo "==> Building frontend with VITE_API_URL=${BACKEND_URL}..." +CLOUDBUILD_CONF=$(mktemp /tmp/cloudbuild-frontend-XXXXX.yaml) +cat > "$CLOUDBUILD_CONF" <<'EOF' +steps: + - name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '--build-arg' + - 'VITE_API_URL=$_VITE_API_URL' + - '-t' + - 'gcr.io/$PROJECT_ID/finmind-frontend:latest' + - '.' +images: + - 'gcr.io/$PROJECT_ID/finmind-frontend:latest' +EOF + +gcloud builds submit app/ \ + --config="$CLOUDBUILD_CONF" \ + --substitutions="_VITE_API_URL=${BACKEND_URL}" \ + --project="$PROJECT" + +rm -f "$CLOUDBUILD_CONF" + +# ── 6. Deploy frontend to Cloud Run ── +echo "==> Deploying frontend..." +gcloud run deploy finmind-frontend \ + --image "gcr.io/${PROJECT}/finmind-frontend:latest" \ + --platform managed \ + --region "$REGION" \ + --port 80 \ + --memory 256Mi \ + --cpu 1 \ + --min-instances 1 \ + --max-instances 5 \ + --allow-unauthenticated \ + --project="$PROJECT" + +FRONTEND_URL=$(gcloud run services describe finmind-frontend --region="$REGION" --project="$PROJECT" --format="value(status.url)") + +echo "" +echo "============================================" +echo " FinMind deployed to GCP Cloud Run!" +echo " Backend : ${BACKEND_URL}/health" +echo " Frontend: ${FRONTEND_URL}" +echo "============================================" diff --git a/deploy/heroku-nginx.conf.template b/deploy/heroku-nginx.conf.template new file mode 100644 index 00000000..3188d081 --- /dev/null +++ b/deploy/heroku-nginx.conf.template @@ -0,0 +1,92 @@ +worker_processes auto; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + server { + listen ${PORT}; + server_name _; + + # Backend API routes – reverse proxy to gunicorn + location /health { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /auth/ { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /expenses/ { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /bills/ { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /reminders/ { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /insights/ { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /categories/ { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /dashboard/ { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Frontend SPA – serve static files + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri /index.html; + } + + # Cache static assets + location ~* \.(?:css|js|jpg|jpeg|gif|png|svg|ico|woff2?)$ { + root /usr/share/nginx/html; + expires 7d; + access_log off; + add_header Cache-Control "public, max-age=604800"; + try_files $uri =404; + } + } +} diff --git a/deploy/heroku-start.sh b/deploy/heroku-start.sh new file mode 100644 index 00000000..c317b60f --- /dev/null +++ b/deploy/heroku-start.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e + +# Substitute only $PORT in nginx config (leave nginx variables like $host, $uri intact) +envsubst '$PORT' < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf + +# Initialize database +python -m flask --app wsgi:app init-db + +# Start gunicorn in background +gunicorn -w 2 -k gthread --timeout 120 -b 127.0.0.1:8000 wsgi:app & + +# Wait for gunicorn to be ready +sleep 2 + +# Start nginx in foreground +exec nginx -g "daemon off;" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 00000000..72a7aa8b --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,66 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: ${POSTGRES_USER:-finmind} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-finmind} + POSTGRES_DB: ${POSTGRES_DB:-finmind} + volumes: ["pgdata:/var/lib/postgresql/data"] + restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-finmind} -d ${POSTGRES_DB:-finmind}"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: always + volumes: ["redisdata:/data"] + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./packages/backend + dockerfile: Dockerfile + env_file: [.env] + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + ports: ["8000:8000"] + command: ["sh", "-c", "python -m flask --app wsgi:app init-db && gunicorn --workers=4 --threads=4 --bind 0.0.0.0:8000 --access-logfile - --error-logfile - --timeout 120 wsgi:app"] + restart: always + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + frontend: + build: + context: ./app + dockerfile: Dockerfile + args: + VITE_API_URL: ${VITE_API_URL:-http://localhost:8000} + ports: ["80:80"] + depends_on: + backend: + condition: service_healthy + restart: always + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + pgdata: + redisdata: diff --git a/fly.toml b/fly.toml new file mode 100644 index 00000000..affbf158 --- /dev/null +++ b/fly.toml @@ -0,0 +1,40 @@ +app = "finmind-backend" +primary_region = "iad" +kill_signal = "SIGINT" +kill_timeout = "5s" + +[build] + dockerfile = "packages/backend/Dockerfile" + +[deploy] + release_command = "python -m flask --app wsgi:app init-db" + strategy = "rolling" + +[env] + PORT = "8000" + LOG_LEVEL = "INFO" + +[http_service] + internal_port = 8000 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 1 + processes = ["app"] + + [http_service.concurrency] + type = "requests" + hard_limit = 250 + soft_limit = 200 + +[[http_service.checks]] + grace_period = "30s" + interval = "15s" + method = "GET" + path = "/health" + timeout = "5s" + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" + cpus = 1 diff --git a/heroku.yml b/heroku.yml new file mode 100644 index 00000000..16cd55fd --- /dev/null +++ b/heroku.yml @@ -0,0 +1,17 @@ +setup: + addons: + - plan: heroku-postgresql:essential-0 + - plan: heroku-redis:mini + config: + JWT_SECRET: change-me-in-production + LOG_LEVEL: INFO + +build: + docker: + web: + dockerfile: Dockerfile.heroku + args: + VITE_API_URL: https://${HEROKU_APP_NAME}.herokuapp.com + +run: + web: /app/start.sh diff --git a/k8s/helm/finmind/Chart.yaml b/k8s/helm/finmind/Chart.yaml new file mode 100644 index 00000000..d2e37d66 --- /dev/null +++ b/k8s/helm/finmind/Chart.yaml @@ -0,0 +1,13 @@ +apiVersion: v2 +name: finmind +description: FinMind – AI-powered personal finance manager +type: application +version: 1.0.0 +appVersion: "1.0.0" +keywords: + - finance + - budgeting + - flask + - react +maintainers: + - name: FinMind Team diff --git a/k8s/helm/finmind/templates/_helpers.tpl b/k8s/helm/finmind/templates/_helpers.tpl new file mode 100644 index 00000000..ab975da6 --- /dev/null +++ b/k8s/helm/finmind/templates/_helpers.tpl @@ -0,0 +1,35 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "finmind.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a fully qualified app name. +*/}} +{{- define "finmind.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- printf "%s" $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "finmind.labels" -}} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} + +{{/* +Selector labels for a component +*/}} +{{- define "finmind.selectorLabels" -}} +app.kubernetes.io/name: {{ include "finmind.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/k8s/helm/finmind/templates/backend-deployment.yaml b/k8s/helm/finmind/templates/backend-deployment.yaml new file mode 100644 index 00000000..e351820a --- /dev/null +++ b/k8s/helm/finmind/templates/backend-deployment.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "finmind.fullname" . }}-backend + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + {{- if not .Values.backend.autoscaling.enabled }} + replicas: {{ .Values.backend.replicas }} + {{- end }} + selector: + matchLabels: + app.kubernetes.io/component: backend + {{- include "finmind.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + app.kubernetes.io/component: backend + {{- include "finmind.selectorLabels" . | nindent 8 }} + spec: + initContainers: + - name: init-db + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + command: ["python", "-m", "flask", "--app", "wsgi:app", "init-db"] + envFrom: + - configMapRef: + name: {{ include "finmind.fullname" . }}-config + - secretRef: + name: {{ include "finmind.fullname" . }}-secrets + containers: + - name: backend + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + ports: + - containerPort: {{ .Values.backend.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "finmind.fullname" . }}-config + - secretRef: + name: {{ include "finmind.fullname" . }}-secrets + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} + startupProbe: + httpGet: + path: /health + port: {{ .Values.backend.port }} + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 12 + readinessProbe: + httpGet: + path: /health/ready + port: {{ .Values.backend.port }} + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health + port: {{ .Values.backend.port }} + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 diff --git a/k8s/helm/finmind/templates/backend-hpa.yaml b/k8s/helm/finmind/templates/backend-hpa.yaml new file mode 100644 index 00000000..11da7e40 --- /dev/null +++ b/k8s/helm/finmind/templates/backend-hpa.yaml @@ -0,0 +1,45 @@ +{{- if .Values.backend.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "finmind.fullname" . }}-backend + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "finmind.fullname" . }}-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: + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Percent + value: 25 + periodSeconds: 60 + scaleUp: + stabilizationWindowSeconds: 60 + policies: + - type: Percent + value: 100 + periodSeconds: 30 +{{- end }} diff --git a/k8s/helm/finmind/templates/backend-service.yaml b/k8s/helm/finmind/templates/backend-service.yaml new file mode 100644 index 00000000..973e0960 --- /dev/null +++ b/k8s/helm/finmind/templates/backend-service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "finmind.fullname" . }}-backend + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + type: ClusterIP + ports: + - port: {{ .Values.backend.port }} + targetPort: {{ .Values.backend.port }} + protocol: TCP + name: http + selector: + app.kubernetes.io/component: backend + {{- include "finmind.selectorLabels" . | nindent 4 }} diff --git a/k8s/helm/finmind/templates/configmap.yaml b/k8s/helm/finmind/templates/configmap.yaml new file mode 100644 index 00000000..bcece688 --- /dev/null +++ b/k8s/helm/finmind/templates/configmap.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "finmind.fullname" . }}-config + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} +data: + LOG_LEVEL: {{ .Values.backend.env.LOG_LEVEL | quote }} + PORT: {{ .Values.backend.port | quote }} + POSTGRES_USER: {{ .Values.postgres.username | quote }} + POSTGRES_DB: {{ .Values.postgres.database | quote }} diff --git a/k8s/helm/finmind/templates/frontend-deployment.yaml b/k8s/helm/finmind/templates/frontend-deployment.yaml new file mode 100644 index 00000000..730f8d31 --- /dev/null +++ b/k8s/helm/finmind/templates/frontend-deployment.yaml @@ -0,0 +1,45 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "finmind.fullname" . }}-frontend + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + {{- if not .Values.frontend.autoscaling.enabled }} + replicas: {{ .Values.frontend.replicas }} + {{- end }} + selector: + matchLabels: + app.kubernetes.io/component: frontend + {{- include "finmind.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + app.kubernetes.io/component: frontend + {{- include "finmind.selectorLabels" . | nindent 8 }} + spec: + containers: + - name: frontend + image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}" + imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + ports: + - containerPort: {{ .Values.frontend.port }} + protocol: TCP + resources: + {{- toYaml .Values.frontend.resources | nindent 12 }} + readinessProbe: + httpGet: + path: / + port: {{ .Values.frontend.port }} + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + livenessProbe: + httpGet: + path: / + port: {{ .Values.frontend.port }} + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 3 diff --git a/k8s/helm/finmind/templates/frontend-hpa.yaml b/k8s/helm/finmind/templates/frontend-hpa.yaml new file mode 100644 index 00000000..c5e09c27 --- /dev/null +++ b/k8s/helm/finmind/templates/frontend-hpa.yaml @@ -0,0 +1,27 @@ +{{- if .Values.frontend.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "finmind.fullname" . }}-frontend + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "finmind.fullname" . }}-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 }} + behavior: + scaleDown: + stabilizationWindowSeconds: 300 +{{- end }} diff --git a/k8s/helm/finmind/templates/frontend-service.yaml b/k8s/helm/finmind/templates/frontend-service.yaml new file mode 100644 index 00000000..ad3d2a07 --- /dev/null +++ b/k8s/helm/finmind/templates/frontend-service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "finmind.fullname" . }}-frontend + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + type: ClusterIP + ports: + - port: {{ .Values.frontend.port }} + targetPort: {{ .Values.frontend.port }} + protocol: TCP + name: http + selector: + app.kubernetes.io/component: frontend + {{- include "finmind.selectorLabels" . | nindent 4 }} diff --git a/k8s/helm/finmind/templates/ingress.yaml b/k8s/helm/finmind/templates/ingress.yaml new file mode 100644 index 00000000..d49f7a94 --- /dev/null +++ b/k8s/helm/finmind/templates/ingress.yaml @@ -0,0 +1,48 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "finmind.fullname" . }}-ingress + namespace: {{ .Values.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: + {{- if eq .service "backend" }} + name: {{ include "finmind.fullname" $ }}-backend + port: + number: {{ $.Values.backend.port }} + {{- else }} + name: {{ include "finmind.fullname" $ }}-frontend + port: + number: {{ $.Values.frontend.port }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/k8s/helm/finmind/templates/init-db-job.yaml b/k8s/helm/finmind/templates/init-db-job.yaml new file mode 100644 index 00000000..0308c92a --- /dev/null +++ b/k8s/helm/finmind/templates/init-db-job.yaml @@ -0,0 +1,26 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "finmind.fullname" . }}-init-db + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + annotations: + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-weight: "0" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: 3 + template: + spec: + restartPolicy: OnFailure + containers: + - name: init-db + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + command: ["python", "-m", "flask", "--app", "wsgi:app", "init-db"] + envFrom: + - configMapRef: + name: {{ include "finmind.fullname" . }}-config + - secretRef: + name: {{ include "finmind.fullname" . }}-secrets diff --git a/k8s/helm/finmind/templates/namespace.yaml b/k8s/helm/finmind/templates/namespace.yaml new file mode 100644 index 00000000..4a36bfed --- /dev/null +++ b/k8s/helm/finmind/templates/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} diff --git a/k8s/helm/finmind/templates/networkpolicy.yaml b/k8s/helm/finmind/templates/networkpolicy.yaml new file mode 100644 index 00000000..612f582f --- /dev/null +++ b/k8s/helm/finmind/templates/networkpolicy.yaml @@ -0,0 +1,43 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "finmind.fullname" . }}-postgres-policy + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/component: postgres + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/component: backend + ports: + - protocol: TCP + port: {{ .Values.postgres.port }} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "finmind.fullname" . }}-redis-policy + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/component: redis + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/component: backend + ports: + - protocol: TCP + port: {{ .Values.redis.port }} diff --git a/k8s/helm/finmind/templates/postgres-deployment.yaml b/k8s/helm/finmind/templates/postgres-deployment.yaml new file mode 100644 index 00000000..346cf1da --- /dev/null +++ b/k8s/helm/finmind/templates/postgres-deployment.yaml @@ -0,0 +1,79 @@ +{{- if .Values.postgres.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "finmind.fullname" . }}-postgres + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + app.kubernetes.io/component: postgres +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/component: postgres + {{- include "finmind.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + app.kubernetes.io/component: postgres + {{- include "finmind.selectorLabels" . | nindent 8 }} + spec: + containers: + - name: postgres + image: "{{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}" + ports: + - containerPort: {{ .Values.postgres.port }} + protocol: TCP + env: + - name: POSTGRES_USER + valueFrom: + configMapKeyRef: + name: {{ include "finmind.fullname" . }}-config + key: POSTGRES_USER + - name: POSTGRES_DB + valueFrom: + configMapKeyRef: + name: {{ include "finmind.fullname" . }}-config + key: POSTGRES_DB + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "finmind.fullname" . }}-secrets + key: POSTGRES_PASSWORD + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + resources: + {{- toYaml .Values.postgres.resources | nindent 12 }} + readinessProbe: + exec: + command: + - pg_isready + - -U + - {{ .Values.postgres.username }} + - -d + - {{ .Values.postgres.database }} + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + exec: + command: + - pg_isready + - -U + - {{ .Values.postgres.username }} + - -d + - {{ .Values.postgres.database }} + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + volumes: + - name: postgres-data + persistentVolumeClaim: + claimName: {{ include "finmind.fullname" . }}-postgres-pvc +{{- end }} diff --git a/k8s/helm/finmind/templates/postgres-pvc.yaml b/k8s/helm/finmind/templates/postgres-pvc.yaml new file mode 100644 index 00000000..ddc97de1 --- /dev/null +++ b/k8s/helm/finmind/templates/postgres-pvc.yaml @@ -0,0 +1,19 @@ +{{- if .Values.postgres.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "finmind.fullname" . }}-postgres-pvc + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + app.kubernetes.io/component: postgres +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.postgres.storage.storageClassName }} + storageClassName: {{ .Values.postgres.storage.storageClassName }} + {{- end }} + resources: + requests: + storage: {{ .Values.postgres.storage.size }} +{{- end }} diff --git a/k8s/helm/finmind/templates/postgres-service.yaml b/k8s/helm/finmind/templates/postgres-service.yaml new file mode 100644 index 00000000..b00f3108 --- /dev/null +++ b/k8s/helm/finmind/templates/postgres-service.yaml @@ -0,0 +1,19 @@ +{{- if .Values.postgres.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "finmind.fullname" . }}-postgres + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + app.kubernetes.io/component: postgres +spec: + type: ClusterIP + ports: + - port: {{ .Values.postgres.port }} + targetPort: {{ .Values.postgres.port }} + protocol: TCP + selector: + app.kubernetes.io/component: postgres + {{- include "finmind.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/k8s/helm/finmind/templates/redis-deployment.yaml b/k8s/helm/finmind/templates/redis-deployment.yaml new file mode 100644 index 00000000..f9efefd1 --- /dev/null +++ b/k8s/helm/finmind/templates/redis-deployment.yaml @@ -0,0 +1,50 @@ +{{- if .Values.redis.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "finmind.fullname" . }}-redis + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/component: redis + {{- include "finmind.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + app.kubernetes.io/component: redis + {{- include "finmind.selectorLabels" . | nindent 8 }} + spec: + containers: + - name: redis + image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}" + ports: + - containerPort: {{ .Values.redis.port }} + protocol: TCP + command: + - redis-server + - --appendonly + - "yes" + - --maxmemory + - 256mb + - --maxmemory-policy + - allkeys-lru + resources: + {{- toYaml .Values.redis.resources | nindent 12 }} + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + livenessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 3 +{{- end }} diff --git a/k8s/helm/finmind/templates/redis-service.yaml b/k8s/helm/finmind/templates/redis-service.yaml new file mode 100644 index 00000000..d83158a5 --- /dev/null +++ b/k8s/helm/finmind/templates/redis-service.yaml @@ -0,0 +1,19 @@ +{{- if .Values.redis.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "finmind.fullname" . }}-redis + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + type: ClusterIP + ports: + - port: {{ .Values.redis.port }} + targetPort: {{ .Values.redis.port }} + protocol: TCP + selector: + app.kubernetes.io/component: redis + {{- include "finmind.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/k8s/helm/finmind/templates/secrets.yaml b/k8s/helm/finmind/templates/secrets.yaml new file mode 100644 index 00000000..d52f91f3 --- /dev/null +++ b/k8s/helm/finmind/templates/secrets.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "finmind.fullname" . }}-secrets + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} +type: Opaque +stringData: + DATABASE_URL: {{ .Values.secrets.databaseUrl | quote }} + REDIS_URL: {{ .Values.secrets.redisUrl | quote }} + JWT_SECRET: {{ .Values.secrets.jwtSecret | quote }} + POSTGRES_PASSWORD: {{ .Values.secrets.postgresPassword | quote }} + OPENAI_API_KEY: {{ .Values.secrets.openaiApiKey | quote }} + GEMINI_API_KEY: {{ .Values.secrets.geminiApiKey | quote }} diff --git a/k8s/helm/finmind/templates/servicemonitor.yaml b/k8s/helm/finmind/templates/servicemonitor.yaml new file mode 100644 index 00000000..50268377 --- /dev/null +++ b/k8s/helm/finmind/templates/servicemonitor.yaml @@ -0,0 +1,22 @@ +{{- if .Values.observability.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "finmind.fullname" . }}-backend + namespace: {{ .Values.namespace }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + selector: + matchLabels: + app.kubernetes.io/component: backend + {{- include "finmind.selectorLabels" . | nindent 6 }} + endpoints: + - port: http + path: {{ .Values.observability.serviceMonitor.path }} + interval: {{ .Values.observability.serviceMonitor.interval }} + namespaceSelector: + matchNames: + - {{ .Values.namespace }} +{{- end }} diff --git a/k8s/helm/finmind/values.yaml b/k8s/helm/finmind/values.yaml new file mode 100644 index 00000000..dc2a897b --- /dev/null +++ b/k8s/helm/finmind/values.yaml @@ -0,0 +1,143 @@ +# ── Global ── +namespace: finmind + +# ── Container images ── +backend: + image: + repository: finmind-backend + tag: latest + pullPolicy: IfNotPresent + replicas: 2 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: "1" + memory: 512Mi + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 + targetMemoryUtilizationPercentage: 80 + port: 8000 + env: + LOG_LEVEL: INFO + +frontend: + image: + repository: finmind-frontend + tag: latest + pullPolicy: IfNotPresent + replicas: 2 + resources: + requests: + cpu: 100m + memory: 64Mi + limits: + cpu: 500m + memory: 128Mi + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 5 + targetCPUUtilizationPercentage: 80 + port: 80 + +# ── PostgreSQL ── +postgres: + enabled: true + image: + repository: postgres + tag: "16" + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + storage: + size: 10Gi + storageClassName: "" + port: 5432 + database: finmind + username: finmind + +# ── Redis ── +redis: + enabled: true + image: + repository: redis + tag: 7-alpine + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + port: 6379 + +# ── Secrets (override at install time) ── +secrets: + postgresPassword: finmind + jwtSecret: change-me-in-production + databaseUrl: "postgresql+psycopg2://finmind:finmind@finmind-postgres:5432/finmind" + redisUrl: "redis://finmind-redis:6379/0" + openaiApiKey: "" + geminiApiKey: "" + +# ── Ingress / TLS ── +ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/proxy-body-size: "10m" + cert-manager.io/cluster-issuer: letsencrypt-prod + hosts: + - host: finmind.example.com + paths: + - path: / + pathType: Prefix + service: frontend + - path: /health + pathType: Exact + service: backend + - path: /auth + pathType: Prefix + service: backend + - path: /expenses + pathType: Prefix + service: backend + - path: /bills + pathType: Prefix + service: backend + - path: /reminders + pathType: Prefix + service: backend + - path: /insights + pathType: Prefix + service: backend + - path: /categories + pathType: Prefix + service: backend + - path: /dashboard + pathType: Prefix + service: backend + - path: /docs + pathType: Prefix + service: backend + tls: + - secretName: finmind-tls + hosts: + - finmind.example.com + +# ── Observability ── +observability: + serviceMonitor: + enabled: false + interval: 30s + path: /health diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 00000000..445a900d --- /dev/null +++ b/netlify.toml @@ -0,0 +1,32 @@ +[build] + base = "app/" + command = "npm ci && npm run build" + publish = "dist" + +[build.environment] + NODE_VERSION = "20" + VITE_API_URL = "https://your-backend-url.com" + +# SPA fallback +[[redirects]] + from = "/api/*" + to = "https://your-backend-url.com/:splat" + status = 200 + force = true + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 + +[[headers]] + for = "/assets/*" + [headers.values] + Cache-Control = "public, max-age=604800, immutable" + +[[headers]] + for = "/*" + [headers.values] + X-Frame-Options = "DENY" + X-Content-Type-Options = "nosniff" + Referrer-Policy = "strict-origin-when-cross-origin" diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index 68c4d4f6..678557ba 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -7,18 +7,21 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app -# System deps (psycopg2) RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ libpq-dev \ + curl \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt /app/requirements.txt RUN pip install -r requirements.txt -# Copy backend source COPY app /app/app COPY wsgi.py /app/wsgi.py EXPOSE 8000 -CMD ["gunicorn", "-w", "2", "-k", "gthread", "-b", "0.0.0.0:8000", "wsgi:app"] + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=30s \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 + +CMD ["gunicorn", "-w", "2", "-k", "gthread", "--timeout", "120", "-b", "0.0.0.0:8000", "wsgi:app"] diff --git a/packages/backend/app/__init__.py b/packages/backend/app/__init__.py index cdf76b45..4e6be674 100644 --- a/packages/backend/app/__init__.py +++ b/packages/backend/app/__init__.py @@ -68,6 +68,36 @@ def _after_request(response): def health(): return jsonify(status="ok"), 200 + @app.get("/health/ready") + def health_ready(): + """Deep health check: verifies DB and Redis connectivity.""" + checks = {} + overall = True + + try: + conn = db.engine.raw_connection() + conn.cursor().execute("SELECT 1") + conn.close() + checks["database"] = "connected" + except Exception as e: + checks["database"] = f"error: {e}" + overall = False + + try: + from .extensions import redis_client + + redis_client.ping() + checks["redis"] = "connected" + except Exception as e: + checks["redis"] = f"error: {e}" + overall = False + + status_code = 200 if overall else 503 + return ( + jsonify(status="ok" if overall else "degraded", checks=checks), + status_code, + ) + @app.get("/metrics") def metrics(): obs = app.extensions["observability"] diff --git a/railway.json b/railway.json new file mode 100644 index 00000000..dbaa80c9 --- /dev/null +++ b/railway.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "packages/backend/Dockerfile", + "watchPatterns": ["packages/backend/**"] + }, + "deploy": { + "startCommand": "sh -c 'python -m flask --app wsgi:app init-db && gunicorn -w 2 -k gthread --timeout 120 -b 0.0.0.0:${PORT:-8000} wsgi:app'", + "healthcheckPath": "/health", + "healthcheckTimeout": 30, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 5 + } +} diff --git a/railway.toml b/railway.toml new file mode 100644 index 00000000..fbcb7737 --- /dev/null +++ b/railway.toml @@ -0,0 +1,11 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "packages/backend/Dockerfile" +watchPatterns = ["packages/backend/**"] + +[deploy] +startCommand = "sh -c 'python -m flask --app wsgi:app init-db && gunicorn -w 2 -k gthread --timeout 120 -b 0.0.0.0:${PORT:-8000} wsgi:app'" +healthcheckPath = "/health" +healthcheckTimeout = 30 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 5 diff --git a/render.yaml b/render.yaml new file mode 100644 index 00000000..2027a70e --- /dev/null +++ b/render.yaml @@ -0,0 +1,54 @@ +services: + # ── Backend API ── + - type: web + name: finmind-backend + runtime: docker + dockerfilePath: packages/backend/Dockerfile + dockerContext: packages/backend + plan: free + healthCheckPath: /health + envVars: + - key: DATABASE_URL + fromDatabase: + name: finmind-db + property: connectionString + - key: REDIS_URL + sync: false + - key: JWT_SECRET + generateValue: true + - key: LOG_LEVEL + value: INFO + - key: PORT + value: "8000" + buildCommand: "" + startCommand: >- + sh -c "python -m flask --app wsgi:app init-db && + gunicorn -w 2 -k gthread --timeout 120 -b 0.0.0.0:$PORT wsgi:app" + + # ── Frontend SPA ── + - type: web + name: finmind-frontend + runtime: static + buildCommand: cd app && npm ci && npm run build + staticPublishPath: app/dist + headers: + - path: /* + name: Cache-Control + value: public, max-age=0, must-revalidate + routes: + - type: rewrite + source: /* + destination: /index.html + envVars: + - key: VITE_API_URL + fromService: + type: web + name: finmind-backend + envVarKey: RENDER_EXTERNAL_URL + +databases: + - name: finmind-db + plan: free + databaseName: finmind + user: finmind + postgresMajorVersion: "16" diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..85463d91 --- /dev/null +++ b/vercel.json @@ -0,0 +1,32 @@ +{ + "buildCommand": "cd app && npm ci && npm run build", + "outputDirectory": "app/dist", + "framework": "vite", + "installCommand": "cd app && npm ci", + "rewrites": [ + { + "source": "/api/:path*", + "destination": "https://your-backend-url.com/:path*" + }, + { + "source": "/(.*)", + "destination": "/index.html" + } + ], + "headers": [ + { + "source": "/assets/(.*)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=604800, immutable" } + ] + }, + { + "source": "/(.*)", + "headers": [ + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } + ] + } + ] +}