diff --git a/Tiltfile b/Tiltfile new file mode 100644 index 00000000..d9021ed7 --- /dev/null +++ b/Tiltfile @@ -0,0 +1,117 @@ +# ============================================================================ +# FinMind — Tilt local Kubernetes development workflow +# ============================================================================ +# +# Prerequisites: +# - Docker Desktop or Rancher Desktop with Kubernetes enabled +# - Tilt installed: https://docs.tilt.dev/install.html +# - kubectl configured for local cluster +# +# Usage: +# tilt up # Start development +# tilt down # Tear down +# tilt ci # CI mode (no UI, exit on failure) +# +# Dashboard: http://localhost:10350 +# Frontend: http://localhost:5173 (via port-forward) +# Backend: http://localhost:8000 (via port-forward) +# ============================================================================ + +# --------------------------------------------------------------------------- +# Settings +# --------------------------------------------------------------------------- + +allow_k8s_contexts(['docker-desktop', 'rancher-desktop', 'minikube', 'kind-kind', 'colima']) + +load('ext://namespace', 'namespace_create', 'namespace_inject') +namespace_create('finmind') + +# --------------------------------------------------------------------------- +# Docker builds with live-reload +# --------------------------------------------------------------------------- + +# Backend — Python/Flask with hot-reload via volume sync +docker_build( + 'finmind-backend', + context='./packages/backend', + dockerfile='./packages/backend/Dockerfile', + live_update=[ + sync('./packages/backend/app', '/app/app'), + sync('./packages/backend/wsgi.py', '/app/wsgi.py'), + run('pip install -r requirements.txt', trigger=['./packages/backend/requirements.txt']), + ], +) + +# Frontend — React/Vite with hot-reload +docker_build( + 'finmind-frontend', + context='./app', + dockerfile='./app/Dockerfile', + target='builder', + live_update=[ + sync('./app/src', '/app/src'), + sync('./app/public', '/app/public'), + run('npm install', trigger=['./app/package.json']), + ], +) + +# --------------------------------------------------------------------------- +# Kubernetes resources (from kustomize-style raw manifests) +# --------------------------------------------------------------------------- + +# Infrastructure — postgres, redis, secrets, config +k8s_yaml([ + 'deploy/tilt/namespace.yaml', + 'deploy/tilt/secrets.yaml', + 'deploy/tilt/configmap.yaml', + 'deploy/tilt/postgres.yaml', + 'deploy/tilt/redis.yaml', + 'deploy/tilt/backend.yaml', + 'deploy/tilt/frontend.yaml', +]) + +# --------------------------------------------------------------------------- +# Resource grouping and port-forwards +# --------------------------------------------------------------------------- + +k8s_resource('postgres', + labels=['infra'], + port_forwards=['5432:5432'], +) + +k8s_resource('redis', + labels=['infra'], + port_forwards=['6379:6379'], +) + +k8s_resource('backend', + labels=['app'], + port_forwards=['8000:8000'], + resource_deps=['postgres', 'redis'], +) + +k8s_resource('frontend', + labels=['app'], + port_forwards=['5173:80'], + resource_deps=['backend'], +) + +# --------------------------------------------------------------------------- +# Local commands for convenience +# --------------------------------------------------------------------------- + +local_resource( + 'db-seed', + cmd='kubectl exec -n finmind deploy/backend -- python -m flask --app wsgi:app init-db', + labels=['tasks'], + auto_init=False, + trigger_mode=TRIGGER_MODE_MANUAL, +) + +local_resource( + 'run-tests', + cmd='cd packages/backend && python -m pytest tests/ -v --tb=short', + labels=['tasks'], + auto_init=False, + trigger_mode=TRIGGER_MODE_MANUAL, +) diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 00000000..0cc6e390 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,314 @@ +#!/usr/bin/env bash +# ============================================================================ +# FinMind — Universal One-Click Deployment Script +# ============================================================================ +# +# Usage: +# ./deploy.sh Deploy to the specified platform +# ./deploy.sh --list List all supported platforms +# ./deploy.sh --help Show this help +# +# Supported platforms: +# docker Docker Compose (local/VPS) +# kubernetes Raw Kubernetes manifests +# helm Helm chart (production K8s) +# tilt Tilt local K8s dev workflow +# railway Railway PaaS +# heroku Heroku (Docker) +# render Render Blueprint +# fly Fly.io +# digitalocean DigitalOcean App Platform +# droplet DigitalOcean Droplet (VPS) +# aws AWS ECS Fargate +# gcp GCP Cloud Run +# azure Azure Container Apps +# netlify Netlify (frontend only) +# vercel Vercel (frontend only) +# +# ============================================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$SCRIPT_DIR" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } + +# --------------------------------------------------------------------------- +# Pre-flight checks +# --------------------------------------------------------------------------- + +check_env_file() { + if [[ ! -f "$PROJECT_ROOT/.env" ]]; then + if [[ -f "$PROJECT_ROOT/.env.example" ]]; then + warn ".env not found — copying from .env.example" + cp "$PROJECT_ROOT/.env.example" "$PROJECT_ROOT/.env" + warn "Please edit .env with your production values before deploying." + else + error "No .env file found. Create one from .env.example first." + exit 1 + fi + fi +} + +require_cmd() { + if ! command -v "$1" &>/dev/null; then + error "Required command '$1' not found. Please install it first." + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Platform deployers +# --------------------------------------------------------------------------- + +deploy_docker() { + info "Deploying with Docker Compose..." + require_cmd docker + check_env_file + + docker compose up -d --build + ok "FinMind is running!" + echo " Frontend: http://localhost:5173" + echo " Backend: http://localhost:8000" + echo " Grafana: http://localhost:3000" +} + +deploy_kubernetes() { + info "Deploying to Kubernetes (raw manifests)..." + require_cmd kubectl + check_env_file + + kubectl apply -f deploy/k8s/namespace.yaml + kubectl apply -f deploy/k8s/secrets.example.yaml # User should create real secrets + kubectl apply -f deploy/k8s/app-stack.yaml + kubectl apply -f deploy/k8s/monitoring-stack.yaml + + ok "FinMind deployed to Kubernetes!" + echo " kubectl get pods -n finmind" + echo " kubectl port-forward -n finmind svc/backend 8000:8000" +} + +deploy_helm() { + info "Deploying with Helm..." + require_cmd helm + require_cmd kubectl + + helm upgrade --install finmind deploy/helm/finmind \ + --namespace finmind \ + --create-namespace \ + --wait \ + --timeout 10m \ + "$@" + + ok "FinMind deployed via Helm!" + echo " helm status finmind -n finmind" + echo " kubectl get pods -n finmind" +} + +deploy_tilt() { + info "Starting Tilt local development..." + require_cmd tilt + require_cmd kubectl + + tilt up +} + +deploy_railway() { + info "Deploying to Railway..." + require_cmd railway + + echo "1. Link this project to Railway:" + echo " railway link" + echo "" + echo "2. Deploy:" + echo " railway up" + echo "" + echo "3. Or use the Deploy button in the Railway dashboard:" + echo " https://railway.app/new" + echo "" + echo "Config: deploy/railway/railway.json" +} + +deploy_heroku() { + info "Deploying to Heroku..." + require_cmd heroku + + echo "Option 1: Heroku Button (recommended)" + echo " Click: https://heroku.com/deploy?template=https://github.com/rohitdash08/FinMind" + echo "" + echo "Option 2: CLI" + echo " heroku create finmind-app" + echo " heroku addons:create heroku-postgresql:essential-0" + echo " heroku addons:create heroku-redis:mini" + echo " heroku stack:set container" + echo " git push heroku main" + echo "" + echo "Config: deploy/heroku/heroku.yml, deploy/heroku/app.json" +} + +deploy_render() { + info "Deploying to Render..." + echo "Option 1: Render Blueprint (recommended)" + echo " 1. Go to https://render.com/deploy" + echo " 2. Connect your GitHub repo" + echo " 3. Render will auto-detect deploy/render/render.yaml" + echo "" + echo "Option 2: Manual setup" + echo " Follow the spec in deploy/render/render.yaml" +} + +deploy_fly() { + info "Deploying to Fly.io..." + require_cmd flyctl + + bash deploy/fly/deploy.sh "$@" +} + +deploy_digitalocean() { + info "Deploying to DigitalOcean App Platform..." + require_cmd doctl + + echo "1. Create the app:" + echo " doctl apps create --spec deploy/digitalocean/app-spec.yaml" + echo "" + echo "2. Or use the DO dashboard:" + echo " Import deploy/digitalocean/app-spec.yaml" +} + +deploy_droplet() { + info "Deploying to DigitalOcean Droplet..." + echo "1. Create an Ubuntu 22.04 droplet" + echo "2. SSH in and run:" + echo " curl -sSL https://raw.githubusercontent.com/rohitdash08/FinMind/main/deploy/digitalocean/droplet-setup.sh | bash" + echo "" + echo "Or copy and run deploy/digitalocean/droplet-setup.sh manually." +} + +deploy_aws() { + info "Deploying to AWS ECS Fargate..." + require_cmd aws + + bash deploy/aws/deploy.sh "$@" +} + +deploy_gcp() { + info "Deploying to GCP Cloud Run..." + require_cmd gcloud + + bash deploy/gcp/deploy.sh "$@" +} + +deploy_azure() { + info "Deploying to Azure Container Apps..." + require_cmd az + + bash deploy/azure/deploy.sh "$@" +} + +deploy_netlify() { + info "Deploying frontend to Netlify..." + require_cmd netlify + + echo "1. Connect repo to Netlify dashboard" + echo "2. Set base directory: app" + echo "3. Set build command: npm run build" + echo "4. Set publish directory: app/dist" + echo "5. Add env var: VITE_API_URL=https://your-backend-url" + echo "" + echo "Or use CLI:" + echo " cd app && netlify deploy --prod" + echo "" + echo "Config: deploy/netlify/netlify.toml" +} + +deploy_vercel() { + info "Deploying frontend to Vercel..." + require_cmd vercel + + echo "1. Import project from GitHub in Vercel dashboard" + echo "2. Set root directory: app" + echo "3. Framework: Vite" + echo "4. Add env var: VITE_API_URL=https://your-backend-url" + echo "" + echo "Or use CLI:" + echo " cd app && vercel --prod" + echo "" + echo "Config: deploy/vercel/vercel.json" +} + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +show_help() { + echo "FinMind — Universal One-Click Deployment" + echo "" + echo "Usage: ./deploy.sh [options]" + echo "" + echo "Full-stack platforms:" + echo " docker Docker Compose (local/VPS)" + echo " kubernetes Raw Kubernetes manifests" + echo " helm Helm chart (production K8s)" + echo " tilt Tilt local K8s dev workflow" + echo " railway Railway PaaS" + echo " heroku Heroku (Docker)" + echo " render Render Blueprint" + echo " fly Fly.io" + echo " digitalocean DigitalOcean App Platform" + echo " droplet DigitalOcean Droplet (VPS)" + echo " aws AWS ECS Fargate" + echo " gcp GCP Cloud Run" + echo " azure Azure Container Apps" + echo "" + echo "Frontend-only platforms:" + echo " netlify Netlify" + echo " vercel Vercel" + echo "" + echo "Options:" + echo " --list List all platforms" + echo " --help Show this help" +} + +list_platforms() { + echo "Supported platforms:" + echo " docker, kubernetes, helm, tilt" + echo " railway, heroku, render, fly" + echo " digitalocean, droplet, aws, gcp, azure" + echo " netlify, vercel" +} + +case "${1:-}" in + docker) deploy_docker ;; + kubernetes|k8s) deploy_kubernetes ;; + helm) shift; deploy_helm "$@" ;; + tilt) deploy_tilt ;; + railway) deploy_railway ;; + heroku) deploy_heroku ;; + render) deploy_render ;; + fly) shift; deploy_fly "$@" ;; + digitalocean|do) deploy_digitalocean ;; + droplet) deploy_droplet ;; + aws) shift; deploy_aws "$@" ;; + gcp) shift; deploy_gcp "$@" ;; + azure) shift; deploy_azure "$@" ;; + netlify) deploy_netlify ;; + vercel) deploy_vercel ;; + --list) list_platforms ;; + --help|-h|"") show_help ;; + *) + error "Unknown platform: $1" + echo "Run './deploy.sh --list' to see supported platforms." + exit 1 + ;; +esac diff --git a/deploy/aws/deploy.sh b/deploy/aws/deploy.sh new file mode 100755 index 00000000..2dad1ff8 --- /dev/null +++ b/deploy/aws/deploy.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# ============================================================================ +# FinMind — AWS ECS Fargate Deployment Script +# ============================================================================ +# Deploys the FinMind backend (Python/Flask) and frontend (React/Vite/nginx) +# to AWS ECS Fargate with ECR, ALB, CloudWatch logging, and auto-scaling. +# +# Prerequisites: +# - AWS CLI v2 configured with appropriate IAM permissions +# - Docker installed and running +# - jq installed +# +# Usage: +# ./deploy.sh [--region us-east-1] [--env production] +# ============================================================================ + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration — override via environment variables or CLI flags +# --------------------------------------------------------------------------- +AWS_REGION="${AWS_REGION:-us-east-1}" +ENVIRONMENT="${ENVIRONMENT:-production}" +PROJECT_NAME="finmind" +CLUSTER_NAME="${PROJECT_NAME}-cluster" +SERVICE_NAME="${PROJECT_NAME}-service" +BACKEND_REPO="${PROJECT_NAME}-backend" +FRONTEND_REPO="${PROJECT_NAME}-frontend" +BACKEND_DOCKERFILE="packages/backend/Dockerfile" +FRONTEND_DOCKERFILE="app/Dockerfile" +DESIRED_COUNT=2 +CPU=512 +MEMORY=1024 + +# Parse CLI arguments +while [[ $# -gt 0 ]]; do + case $1 in + --region) AWS_REGION="$2"; shift 2 ;; + --env) ENVIRONMENT="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +ECR_BASE="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +echo "=== FinMind AWS ECS Fargate Deployment ===" +echo "Region: ${AWS_REGION}" +echo "Account: ${ACCOUNT_ID}" +echo "Environment: ${ENVIRONMENT}" +echo "Project Root: ${PROJECT_ROOT}" +echo "" + +# --------------------------------------------------------------------------- +# Step 1: Authenticate Docker with ECR +# --------------------------------------------------------------------------- +echo ">>> Step 1: Authenticating Docker with ECR..." +aws ecr get-login-password --region "${AWS_REGION}" \ + | docker login --username AWS --password-stdin "${ECR_BASE}" + +# --------------------------------------------------------------------------- +# Step 2: Create ECR repositories (idempotent) +# --------------------------------------------------------------------------- +echo ">>> Step 2: Creating ECR repositories..." +for repo in "${BACKEND_REPO}" "${FRONTEND_REPO}"; do + if ! aws ecr describe-repositories --repository-names "${repo}" --region "${AWS_REGION}" >/dev/null 2>&1; then + aws ecr create-repository \ + --repository-name "${repo}" \ + --region "${AWS_REGION}" \ + --image-scanning-configuration scanOnPush=true \ + --encryption-configuration encryptionType=AES256 + echo " Created ECR repository: ${repo}" + else + echo " ECR repository already exists: ${repo}" + fi + + # Set lifecycle policy to keep only the last 10 images + aws ecr put-lifecycle-policy \ + --repository-name "${repo}" \ + --region "${AWS_REGION}" \ + --lifecycle-policy-text '{ + "rules": [{ + "rulePriority": 1, + "description": "Keep last 10 images", + "selection": { + "tagStatus": "any", + "countType": "imageCountMoreThan", + "countNumber": 10 + }, + "action": { "type": "expire" } + }] + }' >/dev/null +done + +# --------------------------------------------------------------------------- +# Step 3: Build and push Docker images +# --------------------------------------------------------------------------- +echo ">>> Step 3: Building and pushing Docker images..." + +IMAGE_TAG="${ENVIRONMENT}-$(date +%Y%m%d-%H%M%S)" + +# Backend +echo " Building backend image..." +docker build \ + -t "${ECR_BASE}/${BACKEND_REPO}:${IMAGE_TAG}" \ + -t "${ECR_BASE}/${BACKEND_REPO}:latest" \ + -f "${PROJECT_ROOT}/${BACKEND_DOCKERFILE}" \ + "${PROJECT_ROOT}/packages/backend" + +echo " Pushing backend image..." +docker push "${ECR_BASE}/${BACKEND_REPO}:${IMAGE_TAG}" +docker push "${ECR_BASE}/${BACKEND_REPO}:latest" + +# Frontend +echo " Building frontend image..." +docker build \ + -t "${ECR_BASE}/${FRONTEND_REPO}:${IMAGE_TAG}" \ + -t "${ECR_BASE}/${FRONTEND_REPO}:latest" \ + -f "${PROJECT_ROOT}/${FRONTEND_DOCKERFILE}" \ + "${PROJECT_ROOT}/app" + +echo " Pushing frontend image..." +docker push "${ECR_BASE}/${FRONTEND_REPO}:${IMAGE_TAG}" +docker push "${ECR_BASE}/${FRONTEND_REPO}:latest" + +# --------------------------------------------------------------------------- +# Step 4: Create CloudWatch log group +# --------------------------------------------------------------------------- +echo ">>> Step 4: Creating CloudWatch log group..." +aws logs create-log-group \ + --log-group-name "/ecs/${PROJECT_NAME}" \ + --region "${AWS_REGION}" 2>/dev/null || true + +aws logs put-retention-policy \ + --log-group-name "/ecs/${PROJECT_NAME}" \ + --retention-in-days 30 \ + --region "${AWS_REGION}" + +# --------------------------------------------------------------------------- +# Step 5: Create ECS cluster +# --------------------------------------------------------------------------- +echo ">>> Step 5: Creating ECS cluster..." +if ! aws ecs describe-clusters --clusters "${CLUSTER_NAME}" --region "${AWS_REGION}" \ + --query "clusters[?status=='ACTIVE'].clusterName" --output text | grep -q "${CLUSTER_NAME}"; then + aws ecs create-cluster \ + --cluster-name "${CLUSTER_NAME}" \ + --region "${AWS_REGION}" \ + --capacity-providers FARGATE FARGATE_SPOT \ + --default-capacity-provider-strategy \ + capacityProvider=FARGATE,weight=1,base=1 \ + capacityProvider=FARGATE_SPOT,weight=3 \ + --setting name=containerInsights,value=enabled \ + --tags key=Project,value=FinMind key=Environment,value="${ENVIRONMENT}" + echo " Cluster created: ${CLUSTER_NAME}" +else + echo " Cluster already exists: ${CLUSTER_NAME}" +fi + +# --------------------------------------------------------------------------- +# Step 6: Register task definition +# --------------------------------------------------------------------------- +echo ">>> Step 6: Registering ECS task definition..." + +# Substitute placeholders in the task definition +TASK_DEF=$(cat "${SCRIPT_DIR}/ecs-task-definition.json" \ + | sed "s/ACCOUNT_ID/${ACCOUNT_ID}/g" \ + | sed "s/REGION/${AWS_REGION}/g") + +TASK_DEF_ARN=$(echo "${TASK_DEF}" | aws ecs register-task-definition \ + --cli-input-json file:///dev/stdin \ + --region "${AWS_REGION}" \ + --query 'taskDefinition.taskDefinitionArn' \ + --output text) + +echo " Registered task definition: ${TASK_DEF_ARN}" + +# --------------------------------------------------------------------------- +# Step 7: Create or update ECS service +# --------------------------------------------------------------------------- +echo ">>> Step 7: Creating/updating ECS service..." + +if aws ecs describe-services --cluster "${CLUSTER_NAME}" --services "${SERVICE_NAME}" \ + --region "${AWS_REGION}" --query "services[?status=='ACTIVE'].serviceName" \ + --output text 2>/dev/null | grep -q "${SERVICE_NAME}"; then + + # Update existing service with new task definition + aws ecs update-service \ + --cluster "${CLUSTER_NAME}" \ + --service "${SERVICE_NAME}" \ + --task-definition "${TASK_DEF_ARN}" \ + --desired-count "${DESIRED_COUNT}" \ + --force-new-deployment \ + --region "${AWS_REGION}" >/dev/null + + echo " Updated service: ${SERVICE_NAME}" +else + # Create new service — requires VPC/subnet/SG/ALB setup done beforehand + echo " NOTE: Creating a new service requires networking resources." + echo " Ensure VPC, subnets, security groups, and ALB target groups exist." + echo " Update subnet/SG/TG ARNs in ecs-service.json before running." + + aws ecs create-service \ + --cluster "${CLUSTER_NAME}" \ + --service-name "${SERVICE_NAME}" \ + --task-definition "${TASK_DEF_ARN}" \ + --desired-count "${DESIRED_COUNT}" \ + --launch-type FARGATE \ + --platform-version LATEST \ + --deployment-configuration "maximumPercent=200,minimumHealthyPercent=100" \ + --health-check-grace-period-seconds 120 \ + --region "${AWS_REGION}" \ + --network-configuration "$(jq -c '.networkConfiguration' "${SCRIPT_DIR}/ecs-service.json")" \ + --load-balancers "$(jq -c '.loadBalancers' "${SCRIPT_DIR}/ecs-service.json")" \ + --tags key=Project,value=FinMind key=Environment,value="${ENVIRONMENT}" >/dev/null + + echo " Created service: ${SERVICE_NAME}" +fi + +# --------------------------------------------------------------------------- +# Step 8: Configure auto-scaling +# --------------------------------------------------------------------------- +echo ">>> Step 8: Configuring auto-scaling..." + +# Register the scalable target +aws application-autoscaling register-scalable-target \ + --service-namespace ecs \ + --resource-id "service/${CLUSTER_NAME}/${SERVICE_NAME}" \ + --scalable-dimension ecs:service:DesiredCount \ + --min-capacity 2 \ + --max-capacity 10 \ + --region "${AWS_REGION}" 2>/dev/null || true + +# CPU-based scaling policy +aws application-autoscaling put-scaling-policy \ + --service-namespace ecs \ + --resource-id "service/${CLUSTER_NAME}/${SERVICE_NAME}" \ + --scalable-dimension ecs:service:DesiredCount \ + --policy-name "${PROJECT_NAME}-cpu-scaling" \ + --policy-type TargetTrackingScaling \ + --target-tracking-scaling-policy-configuration '{ + "TargetValue": 70.0, + "PredefinedMetricSpecification": { + "PredefinedMetricType": "ECSServiceAverageCPUUtilization" + }, + "ScaleInCooldown": 300, + "ScaleOutCooldown": 60 + }' \ + --region "${AWS_REGION}" >/dev/null + +echo " Auto-scaling configured (CPU target: 70%, min: 2, max: 10)" + +# --------------------------------------------------------------------------- +# Step 9: Wait for deployment to stabilize +# --------------------------------------------------------------------------- +echo ">>> Step 9: Waiting for service to stabilize..." +aws ecs wait services-stable \ + --cluster "${CLUSTER_NAME}" \ + --services "${SERVICE_NAME}" \ + --region "${AWS_REGION}" + +echo "" +echo "=== Deployment Complete ===" +echo "Image tag: ${IMAGE_TAG}" +echo "Cluster: ${CLUSTER_NAME}" +echo "Service: ${SERVICE_NAME}" +echo "Task def: ${TASK_DEF_ARN}" +echo "" +echo "View logs:" +echo " aws logs tail /ecs/${PROJECT_NAME} --follow --region ${AWS_REGION}" +echo "" +echo "View service status:" +echo " aws ecs describe-services --cluster ${CLUSTER_NAME} --services ${SERVICE_NAME} --region ${AWS_REGION}" diff --git a/deploy/aws/ecs-service.json b/deploy/aws/ecs-service.json new file mode 100644 index 00000000..0fa0620d --- /dev/null +++ b/deploy/aws/ecs-service.json @@ -0,0 +1,70 @@ +{ + "serviceName": "finmind-service", + "cluster": "finmind-cluster", + "taskDefinition": "finmind", + "desiredCount": 2, + "launchType": "FARGATE", + "platformVersion": "LATEST", + "deploymentConfiguration": { + "maximumPercent": 200, + "minimumHealthyPercent": 100, + "deploymentCircuitBreaker": { + "enable": true, + "rollback": true + } + }, + "networkConfiguration": { + "awsvpcConfiguration": { + "subnets": [ + "subnet-XXXXXXXX", + "subnet-YYYYYYYY" + ], + "securityGroups": [ + "sg-XXXXXXXX" + ], + "assignPublicIp": "ENABLED" + } + }, + "loadBalancers": [ + { + "targetGroupArn": "arn:aws:elasticloadbalancing:REGION:ACCOUNT_ID:targetgroup/finmind-backend-tg/XXXXXXXX", + "containerName": "finmind-backend", + "containerPort": 8000 + }, + { + "targetGroupArn": "arn:aws:elasticloadbalancing:REGION:ACCOUNT_ID:targetgroup/finmind-frontend-tg/YYYYYYYY", + "containerName": "finmind-frontend", + "containerPort": 80 + } + ], + "healthCheckGracePeriodSeconds": 120, + "schedulingStrategy": "REPLICA", + "enableECSManagedTags": true, + "propagateTags": "SERVICE", + "tags": [ + { "key": "Project", "value": "FinMind" }, + { "key": "Environment", "value": "production" } + ], + "serviceRegistries": [], + "capacityProviderStrategy": [], + "autoScaling": { + "minCapacity": 2, + "maxCapacity": 10, + "targetTrackingPolicies": [ + { + "policyName": "cpu-target-tracking", + "targetValue": 70.0, + "predefinedMetricType": "ECSServiceAverageCPUUtilization", + "scaleInCooldown": 300, + "scaleOutCooldown": 60 + }, + { + "policyName": "memory-target-tracking", + "targetValue": 80.0, + "predefinedMetricType": "ECSServiceAverageMemoryUtilization", + "scaleInCooldown": 300, + "scaleOutCooldown": 60 + } + ] + } +} diff --git a/deploy/aws/ecs-task-definition.json b/deploy/aws/ecs-task-definition.json new file mode 100644 index 00000000..5b8bcc4a --- /dev/null +++ b/deploy/aws/ecs-task-definition.json @@ -0,0 +1,93 @@ +{ + "family": "finmind", + "networkMode": "awsvpc", + "requiresCompatibilities": ["FARGATE"], + "cpu": "512", + "memory": "1024", + "executionRoleArn": "arn:aws:iam::ACCOUNT_ID:role/ecsTaskExecutionRole", + "taskRoleArn": "arn:aws:iam::ACCOUNT_ID:role/ecsTaskRole", + "containerDefinitions": [ + { + "name": "finmind-backend", + "image": "ACCOUNT_ID.dkr.ecr.REGION.amazonaws.com/finmind-backend:latest", + "essential": true, + "portMappings": [ + { + "containerPort": 8000, + "protocol": "tcp" + } + ], + "environment": [ + { "name": "LOG_LEVEL", "value": "info" }, + { "name": "GEMINI_MODEL", "value": "gemini-pro" } + ], + "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" + }, + { + "name": "GEMINI_API_KEY", + "valueFrom": "arn:aws:secretsmanager:REGION:ACCOUNT_ID:secret:finmind/GEMINI_API_KEY" + } + ], + "healthCheck": { + "command": ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"], + "interval": 30, + "timeout": 5, + "retries": 3, + "startPeriod": 60 + }, + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/finmind", + "awslogs-region": "us-east-1", + "awslogs-stream-prefix": "backend", + "awslogs-create-group": "true" + } + }, + "cpu": 256, + "memory": 512, + "memoryReservation": 256 + }, + { + "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", "curl -f http://localhost:80/ || exit 1"], + "interval": 30, + "timeout": 5, + "retries": 3, + "startPeriod": 30 + }, + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/finmind", + "awslogs-region": "us-east-1", + "awslogs-stream-prefix": "frontend", + "awslogs-create-group": "true" + } + }, + "cpu": 256, + "memory": 512, + "memoryReservation": 128 + } + ] +} diff --git a/deploy/azure/bicep/main.bicep b/deploy/azure/bicep/main.bicep new file mode 100644 index 00000000..24b081e7 --- /dev/null +++ b/deploy/azure/bicep/main.bicep @@ -0,0 +1,433 @@ +// ============================================================================ +// FinMind — Azure Bicep Template +// ============================================================================ +// Deploys: +// - Container App Environment with Log Analytics +// - Backend Container App (Python/Flask, port 8000) +// - Frontend Container App (React/nginx, port 80) +// - Azure Database for PostgreSQL Flexible Server +// +// Usage: +// az deployment group create \ +// --resource-group finmind-rg \ +// --template-file main.bicep \ +// --parameters backendImage=.azurecr.io/finmind-backend:latest \ +// frontendImage=.azurecr.io/finmind-frontend:latest \ +// postgresAdminPassword= \ +// jwtSecret= \ +// geminiApiKey= +// ============================================================================ + +// --------------------------------------------------------------------------- +// Parameters +// --------------------------------------------------------------------------- + +@description('Name prefix for all resources') +param projectName string = 'finmind' + +@description('Azure region for deployment') +param location string = resourceGroup().location + +@description('Backend container image (full ACR path with tag)') +param backendImage string + +@description('Frontend container image (full ACR path with tag)') +param frontendImage string + +@description('ACR login server (e.g., finmindacr.azurecr.io)') +param acrLoginServer string + +@description('ACR username') +param acrUsername string + +@secure() +@description('ACR password') +param acrPassword string + +@secure() +@description('PostgreSQL admin password') +param postgresAdminPassword string + +@description('PostgreSQL admin username') +param postgresAdminUser string = 'finmindadmin' + +@secure() +@description('JWT signing secret') +param jwtSecret string + +@secure() +@description('Google Gemini API key') +param geminiApiKey string + +@description('Gemini model name') +param geminiModel string = 'gemini-pro' + +@description('Log level for backend') +param logLevel string = 'info' + +@description('Minimum backend replicas') +param backendMinReplicas int = 1 + +@description('Maximum backend replicas') +param backendMaxReplicas int = 10 + +@description('Minimum frontend replicas') +param frontendMinReplicas int = 0 + +@description('Maximum frontend replicas') +param frontendMaxReplicas int = 5 + +// --------------------------------------------------------------------------- +// Variables +// --------------------------------------------------------------------------- + +var envName = '${projectName}-env' +var backendAppName = '${projectName}-backend' +var frontendAppName = '${projectName}-frontend' +var postgresServerName = '${projectName}-pgserver' +var logAnalyticsName = '${projectName}-logs' +var postgresDbName = 'finmind' + +// --------------------------------------------------------------------------- +// Log Analytics Workspace +// --------------------------------------------------------------------------- + +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2022-10-01' = { + name: logAnalyticsName + location: location + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + } + tags: { + app: projectName + environment: 'production' + } +} + +// --------------------------------------------------------------------------- +// Container App Environment +// --------------------------------------------------------------------------- + +resource containerAppEnv 'Microsoft.App/managedEnvironments@2023-05-01' = { + name: envName + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalytics.properties.customerId + sharedKey: logAnalytics.listKeys().primarySharedKey + } + } + } + tags: { + app: projectName + environment: 'production' + } +} + +// --------------------------------------------------------------------------- +// Azure Database for PostgreSQL Flexible Server +// --------------------------------------------------------------------------- + +resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2023-03-01-preview' = { + name: postgresServerName + location: location + sku: { + name: 'Standard_B1ms' + tier: 'Burstable' + } + properties: { + version: '15' + administratorLogin: postgresAdminUser + administratorLoginPassword: postgresAdminPassword + storage: { + storageSizeGB: 32 + } + backup: { + backupRetentionDays: 7 + geoRedundantBackup: 'Disabled' + } + highAvailability: { + mode: 'Disabled' + } + } + tags: { + app: projectName + environment: 'production' + } +} + +resource postgresDb 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2023-03-01-preview' = { + parent: postgresServer + name: postgresDbName + properties: { + charset: 'UTF8' + collation: 'en_US.utf8' + } +} + +// Allow Azure services to connect +resource postgresFirewall 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-03-01-preview' = { + parent: postgresServer + name: 'AllowAzureServices' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' + } +} + +// --------------------------------------------------------------------------- +// Backend Container App +// --------------------------------------------------------------------------- + +resource backendApp 'Microsoft.App/containerApps@2023-05-01' = { + name: backendAppName + location: location + properties: { + managedEnvironmentId: containerAppEnv.id + configuration: { + ingress: { + external: true + targetPort: 8000 + transport: 'http' + allowInsecure: false + traffic: [ + { + latestRevision: true + weight: 100 + } + ] + } + registries: [ + { + server: acrLoginServer + username: acrUsername + passwordSecretRef: 'acr-password' + } + ] + secrets: [ + { + name: 'acr-password' + value: acrPassword + } + { + name: 'database-url' + value: 'postgresql://${postgresAdminUser}:${postgresAdminPassword}@${postgresServer.properties.fullyQualifiedDomainName}:5432/${postgresDbName}?sslmode=require' + } + { + name: 'jwt-secret' + value: jwtSecret + } + { + name: 'gemini-api-key' + value: geminiApiKey + } + ] + } + template: { + containers: [ + { + name: 'backend' + image: backendImage + resources: { + cpu: json('0.5') + memory: '1Gi' + } + env: [ + { + name: 'DATABASE_URL' + secretRef: 'database-url' + } + { + name: 'REDIS_URL' + value: 'redis://localhost:6379/0' // Update with actual Redis URL + } + { + name: 'JWT_SECRET' + secretRef: 'jwt-secret' + } + { + name: 'GEMINI_API_KEY' + secretRef: 'gemini-api-key' + } + { + name: 'LOG_LEVEL' + value: logLevel + } + { + name: 'GEMINI_MODEL' + value: geminiModel + } + ] + probes: [ + { + type: 'Liveness' + httpGet: { + path: '/health' + port: 8000 + } + initialDelaySeconds: 30 + periodSeconds: 30 + failureThreshold: 3 + timeoutSeconds: 5 + } + { + type: 'Readiness' + httpGet: { + path: '/health' + port: 8000 + } + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 3 + timeoutSeconds: 5 + } + { + type: 'Startup' + httpGet: { + path: '/health' + port: 8000 + } + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 10 + timeoutSeconds: 5 + } + ] + } + ] + scale: { + minReplicas: backendMinReplicas + maxReplicas: backendMaxReplicas + rules: [ + { + name: 'http-scaling' + http: { + metadata: { + concurrentRequests: '50' + } + } + } + ] + } + } + } + tags: { + app: projectName + component: 'backend' + environment: 'production' + } +} + +// --------------------------------------------------------------------------- +// Frontend Container App +// --------------------------------------------------------------------------- + +resource frontendApp 'Microsoft.App/containerApps@2023-05-01' = { + name: frontendAppName + location: location + properties: { + managedEnvironmentId: containerAppEnv.id + configuration: { + ingress: { + external: true + targetPort: 80 + transport: 'http' + allowInsecure: false + traffic: [ + { + latestRevision: true + weight: 100 + } + ] + } + registries: [ + { + server: acrLoginServer + username: acrUsername + passwordSecretRef: 'acr-password-fe' + } + ] + secrets: [ + { + name: 'acr-password-fe' + value: acrPassword + } + ] + } + template: { + containers: [ + { + name: 'frontend' + image: frontendImage + resources: { + cpu: json('0.25') + memory: '0.5Gi' + } + env: [ + { + name: 'VITE_API_URL' + value: 'https://${backendApp.properties.configuration.ingress.fqdn}' + } + ] + probes: [ + { + type: 'Liveness' + httpGet: { + path: '/' + port: 80 + } + initialDelaySeconds: 10 + periodSeconds: 30 + failureThreshold: 3 + timeoutSeconds: 5 + } + { + type: 'Readiness' + httpGet: { + path: '/' + port: 80 + } + initialDelaySeconds: 5 + periodSeconds: 10 + failureThreshold: 3 + timeoutSeconds: 5 + } + ] + } + ] + scale: { + minReplicas: frontendMinReplicas + maxReplicas: frontendMaxReplicas + rules: [ + { + name: 'http-scaling' + http: { + metadata: { + concurrentRequests: '100' + } + } + } + ] + } + } + } + tags: { + app: projectName + component: 'frontend' + environment: 'production' + } +} + +// --------------------------------------------------------------------------- +// Outputs +// --------------------------------------------------------------------------- + +output backendUrl string = 'https://${backendApp.properties.configuration.ingress.fqdn}' +output frontendUrl string = 'https://${frontendApp.properties.configuration.ingress.fqdn}' +output postgresServer string = postgresServer.properties.fullyQualifiedDomainName +output containerAppEnvironment string = containerAppEnv.name diff --git a/deploy/azure/deploy.sh b/deploy/azure/deploy.sh new file mode 100755 index 00000000..bad9725a --- /dev/null +++ b/deploy/azure/deploy.sh @@ -0,0 +1,313 @@ +#!/usr/bin/env bash +# ============================================================================ +# FinMind — Azure Container Apps Deployment Script +# ============================================================================ +# Deploys backend (Python/Flask) + frontend (React/nginx) to Azure Container +# Apps with Azure Database for PostgreSQL Flexible Server and Azure Cache +# for Redis. +# +# Prerequisites: +# - Azure CLI authenticated (az login) +# - Docker installed and running +# +# Usage: +# ./deploy.sh [--resource-group finmind-rg] [--location eastus] +# ============================================================================ + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +RESOURCE_GROUP="${AZURE_RESOURCE_GROUP:-finmind-rg}" +LOCATION="${AZURE_LOCATION:-eastus}" +PROJECT_NAME="finmind" +ENVIRONMENT_NAME="${PROJECT_NAME}-env" +ACR_NAME="${PROJECT_NAME}acr" +BACKEND_APP="${PROJECT_NAME}-backend" +FRONTEND_APP="${PROJECT_NAME}-frontend" +PG_SERVER="${PROJECT_NAME}-pgserver" +PG_DB="finmind" +PG_ADMIN="finmindadmin" +REDIS_NAME="${PROJECT_NAME}-redis" +BACKEND_DOCKERFILE="packages/backend/Dockerfile" +FRONTEND_DOCKERFILE="app/Dockerfile" + +# Parse CLI arguments +while [[ $# -gt 0 ]]; do + case $1 in + --resource-group) RESOURCE_GROUP="$2"; shift 2 ;; + --location) LOCATION="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +echo "=== FinMind Azure Container Apps Deployment ===" +echo "Resource Group: ${RESOURCE_GROUP}" +echo "Location: ${LOCATION}" +echo "" + +# --------------------------------------------------------------------------- +# Step 1: Create resource group +# --------------------------------------------------------------------------- +echo ">>> Step 1: Creating resource group..." +az group create \ + --name "${RESOURCE_GROUP}" \ + --location "${LOCATION}" \ + --tags app=finmind environment=production \ + --output none + +# --------------------------------------------------------------------------- +# Step 2: Create Azure Container Registry +# --------------------------------------------------------------------------- +echo ">>> Step 2: Creating Azure Container Registry..." +az acr create \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${ACR_NAME}" \ + --sku Basic \ + --admin-enabled true \ + --output none 2>/dev/null || echo " ACR already exists." + +ACR_LOGIN_SERVER=$(az acr show \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${ACR_NAME}" \ + --query loginServer --output tsv) + +# Login to ACR +az acr login --name "${ACR_NAME}" +echo " ACR: ${ACR_LOGIN_SERVER}" + +# --------------------------------------------------------------------------- +# Step 3: Build and push Docker images +# --------------------------------------------------------------------------- +echo ">>> Step 3: Building and pushing Docker images..." +IMAGE_TAG="$(date +%Y%m%d-%H%M%S)" + +# Backend +echo " Building backend..." +docker build \ + -t "${ACR_LOGIN_SERVER}/${BACKEND_APP}:${IMAGE_TAG}" \ + -t "${ACR_LOGIN_SERVER}/${BACKEND_APP}:latest" \ + -f "${PROJECT_ROOT}/${BACKEND_DOCKERFILE}" \ + "${PROJECT_ROOT}/packages/backend" + +docker push "${ACR_LOGIN_SERVER}/${BACKEND_APP}:${IMAGE_TAG}" +docker push "${ACR_LOGIN_SERVER}/${BACKEND_APP}:latest" + +# Frontend +echo " Building frontend..." +docker build \ + -t "${ACR_LOGIN_SERVER}/${FRONTEND_APP}:${IMAGE_TAG}" \ + -t "${ACR_LOGIN_SERVER}/${FRONTEND_APP}:latest" \ + -f "${PROJECT_ROOT}/${FRONTEND_DOCKERFILE}" \ + "${PROJECT_ROOT}/app" + +docker push "${ACR_LOGIN_SERVER}/${FRONTEND_APP}:${IMAGE_TAG}" +docker push "${ACR_LOGIN_SERVER}/${FRONTEND_APP}:latest" + +# --------------------------------------------------------------------------- +# Step 4: Create Azure Database for PostgreSQL Flexible Server +# --------------------------------------------------------------------------- +echo ">>> Step 4: Creating Azure Database for PostgreSQL..." +PG_PASSWORD=$(openssl rand -base64 32 | tr -d '/@"' | head -c 32) + +if ! az postgres flexible-server show --name "${PG_SERVER}" --resource-group "${RESOURCE_GROUP}" 2>/dev/null; then + az postgres flexible-server create \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${PG_SERVER}" \ + --location "${LOCATION}" \ + --admin-user "${PG_ADMIN}" \ + --admin-password "${PG_PASSWORD}" \ + --sku-name Standard_B1ms \ + --tier Burstable \ + --storage-size 32 \ + --version 15 \ + --public-access 0.0.0.0 \ + --tags app=finmind \ + --output none + + az postgres flexible-server db create \ + --resource-group "${RESOURCE_GROUP}" \ + --server-name "${PG_SERVER}" \ + --database-name "${PG_DB}" \ + --output none + + PG_FQDN=$(az postgres flexible-server show \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${PG_SERVER}" \ + --query fullyQualifiedDomainName --output tsv) + + DATABASE_URL="postgresql://${PG_ADMIN}:${PG_PASSWORD}@${PG_FQDN}:5432/${PG_DB}?sslmode=require" + echo " PostgreSQL created: ${PG_FQDN}" +else + PG_FQDN=$(az postgres flexible-server show \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${PG_SERVER}" \ + --query fullyQualifiedDomainName --output tsv) + DATABASE_URL="postgresql://${PG_ADMIN}:EXISTING_PASSWORD@${PG_FQDN}:5432/${PG_DB}?sslmode=require" + echo " PostgreSQL already exists: ${PG_FQDN}" +fi + +# --------------------------------------------------------------------------- +# Step 5: Create Azure Cache for Redis +# --------------------------------------------------------------------------- +echo ">>> Step 5: Creating Azure Cache for Redis..." +if ! az redis show --name "${REDIS_NAME}" --resource-group "${RESOURCE_GROUP}" 2>/dev/null; then + az redis create \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${REDIS_NAME}" \ + --location "${LOCATION}" \ + --sku Basic \ + --vm-size c0 \ + --tags app=finmind \ + --output none + + echo " Waiting for Redis to provision (this may take several minutes)..." + az redis wait --name "${REDIS_NAME}" --resource-group "${RESOURCE_GROUP}" --created +fi + +REDIS_HOST=$(az redis show \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${REDIS_NAME}" \ + --query hostName --output tsv) +REDIS_KEY=$(az redis list-keys \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${REDIS_NAME}" \ + --query primaryKey --output tsv) +REDIS_URL="rediss://:${REDIS_KEY}@${REDIS_HOST}:6380/0" +echo " Redis: ${REDIS_HOST}" + +# --------------------------------------------------------------------------- +# Step 6: Create Container Apps environment +# --------------------------------------------------------------------------- +echo ">>> Step 6: Creating Container Apps environment..." +LOG_WORKSPACE=$(az monitor log-analytics workspace create \ + --resource-group "${RESOURCE_GROUP}" \ + --workspace-name "${PROJECT_NAME}-logs" \ + --location "${LOCATION}" \ + --query customerId --output tsv 2>/dev/null) + +LOG_KEY=$(az monitor log-analytics workspace get-shared-keys \ + --resource-group "${RESOURCE_GROUP}" \ + --workspace-name "${PROJECT_NAME}-logs" \ + --query primarySharedKey --output tsv) + +az containerapp env create \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${ENVIRONMENT_NAME}" \ + --location "${LOCATION}" \ + --logs-workspace-id "${LOG_WORKSPACE}" \ + --logs-workspace-key "${LOG_KEY}" \ + --output none 2>/dev/null || echo " Environment already exists." + +echo " Container Apps environment: ${ENVIRONMENT_NAME}" + +# --------------------------------------------------------------------------- +# Step 7: Deploy backend Container App +# --------------------------------------------------------------------------- +echo ">>> Step 7: Deploying backend Container App..." + +ACR_USERNAME=$(az acr credential show --name "${ACR_NAME}" --query username --output tsv) +ACR_PASSWORD=$(az acr credential show --name "${ACR_NAME}" --query 'passwords[0].value' --output tsv) + +JWT_SECRET=$(openssl rand -base64 64) + +az containerapp create \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${BACKEND_APP}" \ + --environment "${ENVIRONMENT_NAME}" \ + --image "${ACR_LOGIN_SERVER}/${BACKEND_APP}:${IMAGE_TAG}" \ + --registry-server "${ACR_LOGIN_SERVER}" \ + --registry-username "${ACR_USERNAME}" \ + --registry-password "${ACR_PASSWORD}" \ + --target-port 8000 \ + --ingress external \ + --cpu 0.5 \ + --memory 1.0Gi \ + --min-replicas 1 \ + --max-replicas 10 \ + --env-vars \ + "DATABASE_URL=${DATABASE_URL}" \ + "REDIS_URL=${REDIS_URL}" \ + "JWT_SECRET=${JWT_SECRET}" \ + "GEMINI_API_KEY=${GEMINI_API_KEY:-REPLACE_ME}" \ + "LOG_LEVEL=info" \ + "GEMINI_MODEL=gemini-pro" \ + --scale-rule-name http-scaling \ + --scale-rule-type http \ + --scale-rule-http-concurrency 50 \ + --tags app=finmind component=backend \ + --output none 2>/dev/null || \ +az containerapp update \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${BACKEND_APP}" \ + --image "${ACR_LOGIN_SERVER}/${BACKEND_APP}:${IMAGE_TAG}" \ + --output none + +BACKEND_URL=$(az containerapp show \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${BACKEND_APP}" \ + --query 'properties.configuration.ingress.fqdn' --output tsv) +echo " Backend deployed: https://${BACKEND_URL}" + +# --------------------------------------------------------------------------- +# Step 8: Deploy frontend Container App +# --------------------------------------------------------------------------- +echo ">>> Step 8: Deploying frontend Container App..." +az containerapp create \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${FRONTEND_APP}" \ + --environment "${ENVIRONMENT_NAME}" \ + --image "${ACR_LOGIN_SERVER}/${FRONTEND_APP}:${IMAGE_TAG}" \ + --registry-server "${ACR_LOGIN_SERVER}" \ + --registry-username "${ACR_USERNAME}" \ + --registry-password "${ACR_PASSWORD}" \ + --target-port 80 \ + --ingress external \ + --cpu 0.25 \ + --memory 0.5Gi \ + --min-replicas 0 \ + --max-replicas 5 \ + --env-vars "VITE_API_URL=https://${BACKEND_URL}" \ + --scale-rule-name http-scaling \ + --scale-rule-type http \ + --scale-rule-http-concurrency 100 \ + --tags app=finmind component=frontend \ + --output none 2>/dev/null || \ +az containerapp update \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${FRONTEND_APP}" \ + --image "${ACR_LOGIN_SERVER}/${FRONTEND_APP}:${IMAGE_TAG}" \ + --output none + +FRONTEND_URL=$(az containerapp show \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${FRONTEND_APP}" \ + --query 'properties.configuration.ingress.fqdn' --output tsv) +echo " Frontend deployed: https://${FRONTEND_URL}" + +# --------------------------------------------------------------------------- +# Step 9: Verify health +# --------------------------------------------------------------------------- +echo ">>> Step 9: Verifying deployment..." +sleep 10 +HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://${BACKEND_URL}/health" || echo "000") +if [[ "${HTTP_STATUS}" == "200" ]]; then + echo " Backend health check: PASSED (HTTP ${HTTP_STATUS})" +else + echo " Backend health check: FAILED (HTTP ${HTTP_STATUS})" + echo " Check logs: az containerapp logs show -n ${BACKEND_APP} -g ${RESOURCE_GROUP}" +fi + +echo "" +echo "=== Deployment Complete ===" +echo "Backend: https://${BACKEND_URL}" +echo "Frontend: https://${FRONTEND_URL}" +echo "" +echo "Useful commands:" +echo " az containerapp logs show -n ${BACKEND_APP} -g ${RESOURCE_GROUP} --follow" +echo " az containerapp revision list -n ${BACKEND_APP} -g ${RESOURCE_GROUP} -o table" +echo " az containerapp show -n ${BACKEND_APP} -g ${RESOURCE_GROUP}" diff --git a/deploy/digitalocean/app-spec.yaml b/deploy/digitalocean/app-spec.yaml new file mode 100644 index 00000000..1cef59aa --- /dev/null +++ b/deploy/digitalocean/app-spec.yaml @@ -0,0 +1,109 @@ +# DigitalOcean App Platform Specification — FinMind +# Deploy: doctl apps create --spec app-spec.yaml +# Update: doctl apps update --spec app-spec.yaml + +name: finmind +region: sfo + +alerts: + - rule: DEPLOYMENT_FAILED + - rule: DOMAIN_FAILED + +services: + # ── Backend API ──────────────────────────────────────────── + - name: backend + dockerfile_path: packages/backend/Dockerfile + build_command: "" + source_dir: / + github: + repo: your-org/FinMind + branch: main + deploy_on_push: true + instance_count: 1 + instance_size_slug: basic-xxs + http_port: 8000 + health_check: + http_path: /health + initial_delay_seconds: 15 + period_seconds: 30 + timeout_seconds: 5 + success_threshold: 1 + failure_threshold: 3 + routes: + - path: /api + preserve_path_prefix: true + - path: /health + envs: + - key: DATABASE_URL + scope: RUN_TIME + value: "${db.DATABASE_URL}" + - key: REDIS_URL + scope: RUN_TIME + value: "${redis.REDIS_URL}" + - key: JWT_SECRET + scope: RUN_AND_BUILD_TIME + type: SECRET + value: "CHANGE_ME_GENERATE_WITH_OPENSSL" + - key: GEMINI_API_KEY + scope: RUN_TIME + type: SECRET + value: "CHANGE_ME" + - key: GEMINI_MODEL + scope: RUN_TIME + value: "gemini-pro" + - key: LOG_LEVEL + scope: RUN_TIME + value: "INFO" + - key: PORT + scope: RUN_TIME + value: "8000" + - key: PYTHONUNBUFFERED + scope: RUN_TIME + value: "1" + autoscaling: + min_instance_count: 1 + max_instance_count: 3 + metrics: + cpu: + percent: 75 + + # ── Frontend ─────────────────────────────────────────────── + - name: frontend + dockerfile_path: app/Dockerfile + source_dir: /app + github: + repo: your-org/FinMind + branch: main + deploy_on_push: true + instance_count: 1 + instance_size_slug: basic-xxs + http_port: 80 + health_check: + http_path: / + initial_delay_seconds: 10 + period_seconds: 30 + timeout_seconds: 5 + success_threshold: 1 + failure_threshold: 3 + routes: + - path: / + envs: + - key: VITE_API_URL + scope: BUILD_TIME + value: "${backend.PUBLIC_URL}" + +# ── Managed Databases ───────────────────────────────────────── +databases: + - name: db + engine: PG + version: "16" + size: db-s-dev-database + num_nodes: 1 + production: false + + - name: redis + engine: REDIS + version: "7" + size: db-s-dev-database + num_nodes: 1 + production: false diff --git a/deploy/digitalocean/droplet-setup.sh b/deploy/digitalocean/droplet-setup.sh new file mode 100755 index 00000000..3980e4c2 --- /dev/null +++ b/deploy/digitalocean/droplet-setup.sh @@ -0,0 +1,372 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────── +# FinMind — DigitalOcean Droplet Setup Script +# +# Sets up a fresh Ubuntu 22.04+ droplet with Docker, Docker Compose, +# clones the repo, and starts all services. +# +# Usage: +# 1. Create a Droplet (Ubuntu 22.04, 2GB+ RAM recommended) +# 2. SSH in: ssh root@ +# 3. Run: +# curl -sSL https://raw.githubusercontent.com/your-org/FinMind/main/deploy/digitalocean/droplet-setup.sh | bash +# — or — +# wget -qO- https://raw.githubusercontent.com/your-org/FinMind/main/deploy/digitalocean/droplet-setup.sh | bash +# +# Environment variables (set before running, or you'll be prompted): +# FINMIND_REPO — Git repo URL (default: https://github.com/your-org/FinMind.git) +# JWT_SECRET — JWT signing secret (auto-generated if empty) +# GEMINI_API_KEY — Google Gemini API key +# DOMAIN — Domain name for TLS (optional) +# ────────────────────────────────────────────────────────────── +set -euo pipefail + +# ── Configuration ──────────────────────────────────────────── +FINMIND_REPO="${FINMIND_REPO:-https://github.com/your-org/FinMind.git}" +INSTALL_DIR="/opt/finmind" +COMPOSE_VERSION="2.24.5" + +# ── Colors ─────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log() { echo -e "${BLUE}[FinMind]${NC} $*"; } +ok() { echo -e "${GREEN}[ OK ]${NC} $*"; } +warn() { echo -e "${YELLOW}[ WARN ]${NC} $*"; } +err() { echo -e "${RED}[ERROR ]${NC} $*" >&2; exit 1; } + +# ── Preflight Checks ──────────────────────────────────────── +log "Starting FinMind droplet setup..." + +if [[ "$(id -u)" -ne 0 ]]; then + err "This script must be run as root." +fi + +# Detect OS +if ! grep -qi "ubuntu" /etc/os-release 2>/dev/null; then + warn "This script is designed for Ubuntu. Proceeding anyway..." +fi + +# ── 1. System Updates ─────────────────────────────────────── +log "Updating system packages..." +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get upgrade -y -qq +apt-get install -y -qq \ + ca-certificates \ + curl \ + gnupg \ + lsb-release \ + git \ + ufw \ + fail2ban \ + unattended-upgrades \ + htop \ + jq +ok "System packages updated" + +# ── 2. Docker Installation ────────────────────────────────── +log "Installing Docker..." +if command -v docker &>/dev/null; then + ok "Docker already installed: $(docker --version)" +else + # Add Docker GPG key + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + + # Add Docker repo + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + tee /etc/apt/sources.list.d/docker.list > /dev/null + + apt-get update -qq + apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + ok "Docker installed: $(docker --version)" +fi + +# Enable and start Docker +systemctl enable docker +systemctl start docker + +# ── 3. Firewall Setup ─────────────────────────────────────── +log "Configuring firewall..." +ufw --force reset +ufw default deny incoming +ufw default allow outgoing +ufw allow ssh +ufw allow 80/tcp +ufw allow 443/tcp +ufw --force enable +ok "Firewall configured (SSH, HTTP, HTTPS)" + +# ── 4. Fail2Ban ───────────────────────────────────────────── +log "Configuring fail2ban..." +systemctl enable fail2ban +systemctl start fail2ban +ok "fail2ban active" + +# ── 5. Clone Repository ───────────────────────────────────── +log "Cloning FinMind repository..." +if [[ -d "$INSTALL_DIR" ]]; then + warn "$INSTALL_DIR exists — pulling latest..." + cd "$INSTALL_DIR" + git pull origin main +else + git clone "$FINMIND_REPO" "$INSTALL_DIR" + cd "$INSTALL_DIR" +fi +ok "Repository ready at $INSTALL_DIR" + +# ── 6. Environment Configuration ──────────────────────────── +log "Setting up environment variables..." + +ENV_FILE="$INSTALL_DIR/.env" + +# Generate JWT_SECRET if not set +if [[ -z "${JWT_SECRET:-}" ]]; then + JWT_SECRET=$(openssl rand -hex 64) + warn "Auto-generated JWT_SECRET" +fi + +# Prompt for GEMINI_API_KEY if not set +if [[ -z "${GEMINI_API_KEY:-}" ]]; then + warn "GEMINI_API_KEY not set. Set it in $ENV_FILE after setup." + GEMINI_API_KEY="CHANGE_ME" +fi + +cat > "$ENV_FILE" < "$INSTALL_DIR/docker-compose.prod.yml" <<'COMPOSE' +version: "3.9" + +services: + # ── PostgreSQL ───────────────────────────────────────────── + postgres: + image: postgres:16-alpine + restart: unless-stopped + volumes: + - pgdata:/var/lib/postgresql/data + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - finmind + + # ── Redis ────────────────────────────────────────────────── + redis: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - finmind + + # ── Backend API ──────────────────────────────────────────── + backend: + build: + context: . + dockerfile: packages/backend/Dockerfile + restart: unless-stopped + ports: + - "8000:8000" + env_file: + - .env + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" + reservations: + memory: 256M + cpus: "0.25" + networks: + - finmind + + # ── Frontend ─────────────────────────────────────────────── + frontend: + build: + context: ./app + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "80:80" + - "443:443" + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + reservations: + memory: 128M + cpus: "0.1" + networks: + - finmind + +volumes: + pgdata: + driver: local + redisdata: + driver: local + +networks: + finmind: + driver: bridge +COMPOSE + +ok "Production compose file created" + +# ── 8. Systemd Service ────────────────────────────────────── +log "Creating systemd service..." + +cat > /etc/systemd/system/finmind.service < /etc/docker/daemon.json <<'EOF' +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } +} +EOF +systemctl restart docker +ok "Docker log rotation configured" + +# ── 10. Start Services ────────────────────────────────────── +log "Building and starting services..." +cd "$INSTALL_DIR" +docker compose -f docker-compose.prod.yml --env-file .env up -d --build + +# Wait for services +log "Waiting for services to be healthy..." +sleep 15 + +# ── 11. Health Check ──────────────────────────────────────── +log "Running health checks..." + +check_service() { + local name="$1" url="$2" + if curl -sf --max-time 10 "$url" > /dev/null 2>&1; then + ok "$name is healthy ($url)" + else + warn "$name not responding yet — may still be starting ($url)" + fi +} + +check_service "Backend" "http://localhost:8000/health" +check_service "Frontend" "http://localhost:80/" + +# ── Summary ────────────────────────────────────────────────── +DROPLET_IP=$(curl -sf --max-time 5 http://checkip.amazonaws.com || hostname -I | awk '{print $1}') + +echo "" +echo -e "${GREEN}═══════════════════════════════════════════════════════════${NC}" +echo -e "${GREEN} FinMind Deployment Complete!${NC}" +echo -e "${GREEN}═══════════════════════════════════════════════════════════${NC}" +echo "" +echo -e " Frontend: http://${DROPLET_IP}/" +echo -e " Backend API: http://${DROPLET_IP}:8000/" +echo -e " Health: http://${DROPLET_IP}:8000/health" +echo "" +echo -e " Install dir: ${INSTALL_DIR}" +echo -e " Env file: ${INSTALL_DIR}/.env" +echo "" +echo -e "${YELLOW} Next steps:${NC}" +echo -e " 1. Update GEMINI_API_KEY in ${INSTALL_DIR}/.env" +echo -e " 2. Change POSTGRES_PASSWORD in ${INSTALL_DIR}/.env" +echo -e " 3. Set up a domain and TLS (e.g., with Caddy or Certbot)" +echo -e " 4. Restart: systemctl restart finmind" +echo "" +echo -e " Useful commands:" +echo -e " docker compose -f ${INSTALL_DIR}/docker-compose.prod.yml logs -f" +echo -e " docker compose -f ${INSTALL_DIR}/docker-compose.prod.yml ps" +echo -e " systemctl status finmind" +echo "" diff --git a/deploy/fly/deploy.sh b/deploy/fly/deploy.sh new file mode 100755 index 00000000..050e8015 --- /dev/null +++ b/deploy/fly/deploy.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────── +# FinMind — Fly.io Deployment Script +# Deploys backend + frontend services with managed Postgres & Redis +# +# Prerequisites: +# brew install flyctl (or curl -L https://fly.io/install.sh | sh) +# flyctl auth login +# +# Usage: +# ./deploy.sh # full deploy (infra + apps) +# ./deploy.sh apps # deploy apps only (skip infra) +# ./deploy.sh infra # provision infra only +# ────────────────────────────────────────────────────────────── +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +REGION="${FLY_REGION:-sjc}" + +BACKEND_APP="finmind-backend" +FRONTEND_APP="finmind-frontend" +PG_APP="finmind-db" +REDIS_NAME="finmind-redis" + +# ── Colors ─────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log() { echo -e "${BLUE}[FinMind]${NC} $*"; } +ok() { echo -e "${GREEN}[ OK ]${NC} $*"; } +warn() { echo -e "${YELLOW}[ WARN ]${NC} $*"; } +err() { echo -e "${RED}[ERROR ]${NC} $*" >&2; } + +# ── Preflight ──────────────────────────────────────────────── +preflight() { + if ! command -v flyctl &>/dev/null; then + err "flyctl not found. Install: curl -L https://fly.io/install.sh | sh" + exit 1 + fi + + if ! flyctl auth whoami &>/dev/null; then + err "Not authenticated. Run: flyctl auth login" + exit 1 + fi + + log "Authenticated as $(flyctl auth whoami)" +} + +# ── Provision Infrastructure ───────────────────────────────── +provision_infra() { + log "Provisioning infrastructure in region: $REGION" + + # PostgreSQL + if flyctl postgres list 2>/dev/null | grep -q "$PG_APP"; then + ok "Postgres cluster '$PG_APP' already exists" + else + log "Creating Postgres cluster '$PG_APP'..." + flyctl postgres create \ + --name "$PG_APP" \ + --region "$REGION" \ + --vm-size shared-cpu-1x \ + --initial-cluster-size 1 \ + --volume-size 10 + ok "Postgres cluster created" + fi + + # Redis (Upstash) + if flyctl redis list 2>/dev/null | grep -q "$REDIS_NAME"; then + ok "Redis '$REDIS_NAME' already exists" + else + log "Creating Redis instance '$REDIS_NAME'..." + flyctl redis create \ + --name "$REDIS_NAME" \ + --region "$REGION" \ + --no-replicas + ok "Redis instance created" + fi +} + +# ── Deploy Backend ─────────────────────────────────────────── +deploy_backend() { + log "Deploying backend..." + cd "$PROJECT_ROOT" + + # Launch or deploy + if flyctl apps list 2>/dev/null | grep -q "$BACKEND_APP"; then + log "App '$BACKEND_APP' exists — deploying..." + flyctl deploy \ + --app "$BACKEND_APP" \ + --config "$SCRIPT_DIR/fly.backend.toml" \ + --dockerfile packages/backend/Dockerfile \ + --strategy rolling \ + --wait-timeout 300 + else + log "Creating app '$BACKEND_APP'..." + flyctl launch \ + --name "$BACKEND_APP" \ + --config "$SCRIPT_DIR/fly.backend.toml" \ + --dockerfile packages/backend/Dockerfile \ + --region "$REGION" \ + --no-deploy + + # Attach Postgres + log "Attaching Postgres..." + flyctl postgres attach "$PG_APP" --app "$BACKEND_APP" || warn "Postgres may already be attached" + + # Set secrets + log "Setting secrets (you'll be prompted for values)..." + if [[ -z "${JWT_SECRET:-}" ]]; then + JWT_SECRET=$(openssl rand -hex 64) + warn "Generated JWT_SECRET automatically" + fi + + flyctl secrets set \ + JWT_SECRET="$JWT_SECRET" \ + GEMINI_API_KEY="${GEMINI_API_KEY:-changeme}" \ + --app "$BACKEND_APP" + + # Deploy + flyctl deploy \ + --app "$BACKEND_APP" \ + --config "$SCRIPT_DIR/fly.backend.toml" \ + --dockerfile packages/backend/Dockerfile \ + --strategy rolling \ + --wait-timeout 300 + fi + + ok "Backend deployed: https://$BACKEND_APP.fly.dev" +} + +# ── Deploy Frontend ────────────────────────────────────────── +deploy_frontend() { + log "Deploying frontend..." + cd "$PROJECT_ROOT" + + if flyctl apps list 2>/dev/null | grep -q "$FRONTEND_APP"; then + log "App '$FRONTEND_APP' exists — deploying..." + flyctl deploy \ + --app "$FRONTEND_APP" \ + --config "$SCRIPT_DIR/fly.frontend.toml" \ + --dockerfile app/Dockerfile \ + --strategy rolling \ + --wait-timeout 180 + else + log "Creating app '$FRONTEND_APP'..." + flyctl launch \ + --name "$FRONTEND_APP" \ + --config "$SCRIPT_DIR/fly.frontend.toml" \ + --dockerfile app/Dockerfile \ + --region "$REGION" \ + --no-deploy + + flyctl deploy \ + --app "$FRONTEND_APP" \ + --config "$SCRIPT_DIR/fly.frontend.toml" \ + --dockerfile app/Dockerfile \ + --strategy rolling \ + --wait-timeout 180 + fi + + ok "Frontend deployed: https://$FRONTEND_APP.fly.dev" +} + +# ── Health Check ───────────────────────────────────────────── +check_health() { + log "Running health checks..." + + local backend_url="https://$BACKEND_APP.fly.dev/health" + local frontend_url="https://$FRONTEND_APP.fly.dev/" + + for url in "$backend_url" "$frontend_url"; do + if curl -sf --max-time 10 "$url" > /dev/null 2>&1; then + ok "$url — healthy" + else + warn "$url — not responding (may still be starting)" + fi + done +} + +# ── Main ───────────────────────────────────────────────────── +main() { + preflight + + case "${1:-all}" in + infra) + provision_infra + ;; + apps) + deploy_backend + deploy_frontend + check_health + ;; + backend) + deploy_backend + ;; + frontend) + deploy_frontend + ;; + all) + provision_infra + deploy_backend + deploy_frontend + check_health + ;; + *) + echo "Usage: $0 {all|infra|apps|backend|frontend}" + exit 1 + ;; + esac + + echo "" + ok "Deployment complete!" + log "Backend: https://$BACKEND_APP.fly.dev" + log "Frontend: https://$FRONTEND_APP.fly.dev" + log "Health: https://$BACKEND_APP.fly.dev/health" +} + +main "$@" diff --git a/deploy/fly/fly.backend.toml b/deploy/fly/fly.backend.toml new file mode 100644 index 00000000..87d70faa --- /dev/null +++ b/deploy/fly/fly.backend.toml @@ -0,0 +1,85 @@ +# Fly.io configuration — FinMind Backend +# Deploy: flyctl launch --config fly.backend.toml + +app = "finmind-backend" +primary_region = "sjc" +kill_signal = "SIGINT" +kill_timeout = "5s" + +[build] + dockerfile = "../../packages/backend/Dockerfile" + [build.args] + +[deploy] + strategy = "rolling" + release_command = "echo 'Release phase complete'" + +[env] + PORT = "8000" + LOG_LEVEL = "INFO" + GEMINI_MODEL = "gemini-pro" + PYTHONUNBUFFERED = "1" + +# ── HTTP Service ─────────────────────────────────────────────── +[[services]] + protocol = "tcp" + internal_port = 8000 + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 1 + + [[services.ports]] + port = 80 + handlers = ["http"] + force_https = true + + [[services.ports]] + port = 443 + handlers = ["tls", "http"] + + [services.concurrency] + type = "requests" + hard_limit = 250 + soft_limit = 200 + + # ── Health Checks ────────────────────────────────────────── + [[services.http_checks]] + interval = 15000 # 15s + grace_period = "10s" + method = "GET" + path = "/health" + protocol = "http" + timeout = 5000 # 5s + tls_skip_verify = false + + [[services.tcp_checks]] + interval = "10s" + timeout = "2s" + grace_period = "5s" + +# ── Auto-Scaling ───────────────────────────────────────────── +[machines] + [machines.guest] + cpu_kind = "shared" + cpus = 1 + memory_mb = 512 + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" + processes = ["app"] + +# ── Postgres Cluster (attached) ────────────────────────────── +# Attach with: flyctl postgres attach finmind-db --app finmind-backend +# This auto-sets DATABASE_URL in the app's secrets. +# +# Create Postgres: +# flyctl postgres create --name finmind-db --region sjc --vm-size shared-cpu-1x --initial-cluster-size 1 --volume-size 10 + +# ── Redis (Upstash via Fly) ────────────────────────────────── +# Provision with: flyctl redis create --name finmind-redis --region sjc +# This auto-sets REDIS_URL in the app's secrets. + +# ── Secrets (set via CLI) ──────────────────────────────────── +# flyctl secrets set JWT_SECRET="$(openssl rand -hex 64)" --app finmind-backend +# flyctl secrets set GEMINI_API_KEY="your-api-key" --app finmind-backend diff --git a/deploy/fly/fly.frontend.toml b/deploy/fly/fly.frontend.toml new file mode 100644 index 00000000..c16f034c --- /dev/null +++ b/deploy/fly/fly.frontend.toml @@ -0,0 +1,66 @@ +# Fly.io configuration — FinMind Frontend +# Deploy: flyctl launch --config fly.frontend.toml + +app = "finmind-frontend" +primary_region = "sjc" +kill_signal = "SIGINT" +kill_timeout = "5s" + +[build] + dockerfile = "../../app/Dockerfile" + [build.args] + +[deploy] + strategy = "rolling" + +[env] + VITE_API_URL = "https://finmind-backend.fly.dev" + +# ── HTTP Service ─────────────────────────────────────────────── +[[services]] + protocol = "tcp" + internal_port = 80 + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 1 + + [[services.ports]] + port = 80 + handlers = ["http"] + force_https = true + + [[services.ports]] + port = 443 + handlers = ["tls", "http"] + + [services.concurrency] + type = "requests" + hard_limit = 500 + soft_limit = 400 + + # ── Health Checks ────────────────────────────────────────── + [[services.http_checks]] + interval = 15000 + grace_period = "10s" + method = "GET" + path = "/" + protocol = "http" + timeout = 5000 + tls_skip_verify = false + + [[services.tcp_checks]] + interval = "10s" + timeout = "2s" + grace_period = "5s" + +# ── Machine Size ───────────────────────────────────────────── +[machines] + [machines.guest] + cpu_kind = "shared" + cpus = 1 + memory_mb = 256 + +[[vm]] + size = "shared-cpu-1x" + memory = "256mb" + processes = ["app"] diff --git a/deploy/gcp/cloudbuild.yaml b/deploy/gcp/cloudbuild.yaml new file mode 100644 index 00000000..2c667c5b --- /dev/null +++ b/deploy/gcp/cloudbuild.yaml @@ -0,0 +1,143 @@ +# ============================================================================ +# FinMind — Google Cloud Build Configuration +# ============================================================================ +# Builds backend + frontend Docker images and deploys to Cloud Run. +# +# Trigger this build: +# gcloud builds submit --config deploy/gcp/cloudbuild.yaml . +# +# Required substitutions (set in trigger or CLI): +# _REGION — GCP region (default: us-central1) +# _PROJECT_ID — GCP project ID +# _CLOUD_SQL_CONN — Cloud SQL connection name (project:region:instance) +# _REDIS_HOST — Memorystore Redis IP +# ============================================================================ + +substitutions: + _REGION: us-central1 + _BACKEND_SERVICE: finmind-backend + _FRONTEND_SERVICE: finmind-frontend + _BACKEND_IMAGE: gcr.io/${PROJECT_ID}/finmind-backend + _FRONTEND_IMAGE: gcr.io/${PROJECT_ID}/finmind-frontend + +options: + logging: CLOUD_LOGGING_ONLY + machineType: E2_HIGHCPU_8 + dynamic_substitutions: true + +steps: + # -------------------------------------------------------------------------- + # Step 1: Build backend Docker image + # -------------------------------------------------------------------------- + - id: build-backend + name: gcr.io/cloud-builders/docker + args: + - build + - -t + - ${_BACKEND_IMAGE}:${SHORT_SHA} + - -t + - ${_BACKEND_IMAGE}:latest + - -f + - packages/backend/Dockerfile + - packages/backend + waitFor: ["-"] + + # -------------------------------------------------------------------------- + # Step 2: Build frontend Docker image + # -------------------------------------------------------------------------- + - id: build-frontend + name: gcr.io/cloud-builders/docker + args: + - build + - -t + - ${_FRONTEND_IMAGE}:${SHORT_SHA} + - -t + - ${_FRONTEND_IMAGE}:latest + - -f + - app/Dockerfile + - app + waitFor: ["-"] + + # -------------------------------------------------------------------------- + # Step 3: Push backend image to Container Registry + # -------------------------------------------------------------------------- + - id: push-backend + name: gcr.io/cloud-builders/docker + args: + - push + - --all-tags + - ${_BACKEND_IMAGE} + waitFor: ["build-backend"] + + # -------------------------------------------------------------------------- + # Step 4: Push frontend image to Container Registry + # -------------------------------------------------------------------------- + - id: push-frontend + name: gcr.io/cloud-builders/docker + args: + - push + - --all-tags + - ${_FRONTEND_IMAGE} + waitFor: ["build-frontend"] + + # -------------------------------------------------------------------------- + # Step 5: Deploy backend to Cloud Run + # -------------------------------------------------------------------------- + - id: deploy-backend + name: gcr.io/google.com/cloudsdktool/cloud-sdk + entrypoint: gcloud + args: + - run + - deploy + - ${_BACKEND_SERVICE} + - --image=${_BACKEND_IMAGE}:${SHORT_SHA} + - --region=${_REGION} + - --platform=managed + - --port=8000 + - --cpu=1 + - --memory=512Mi + - --min-instances=1 + - --max-instances=10 + - --concurrency=80 + - --timeout=300s + - --set-env-vars=LOG_LEVEL=info,GEMINI_MODEL=gemini-pro + - --set-secrets=DATABASE_URL=finmind-database-url:latest,REDIS_URL=finmind-redis-url:latest,JWT_SECRET=finmind-jwt-secret:latest,GEMINI_API_KEY=finmind-gemini-api-key:latest + - --add-cloudsql-instances=${_CLOUD_SQL_CONN} + - --vpc-connector=finmind-vpc-connector + - --ingress=all + - --allow-unauthenticated + - --labels=app=finmind,component=backend + waitFor: ["push-backend"] + + # -------------------------------------------------------------------------- + # Step 6: Deploy frontend to Cloud Run + # -------------------------------------------------------------------------- + - id: deploy-frontend + name: gcr.io/google.com/cloudsdktool/cloud-sdk + entrypoint: gcloud + args: + - run + - deploy + - ${_FRONTEND_SERVICE} + - --image=${_FRONTEND_IMAGE}:${SHORT_SHA} + - --region=${_REGION} + - --platform=managed + - --port=80 + - --cpu=1 + - --memory=256Mi + - --min-instances=0 + - --max-instances=5 + - --concurrency=200 + - --timeout=60s + - --ingress=all + - --allow-unauthenticated + - --labels=app=finmind,component=frontend + waitFor: ["push-frontend"] + +images: + - ${_BACKEND_IMAGE}:${SHORT_SHA} + - ${_BACKEND_IMAGE}:latest + - ${_FRONTEND_IMAGE}:${SHORT_SHA} + - ${_FRONTEND_IMAGE}:latest + +timeout: 1800s diff --git a/deploy/gcp/deploy.sh b/deploy/gcp/deploy.sh new file mode 100755 index 00000000..98b2903a --- /dev/null +++ b/deploy/gcp/deploy.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +# ============================================================================ +# FinMind — GCP Cloud Run Deployment Script +# ============================================================================ +# Deploys backend (Python/Flask) + frontend (React/nginx) to Cloud Run +# with Cloud SQL (PostgreSQL) and Memorystore (Redis). +# +# Prerequisites: +# - gcloud CLI authenticated with appropriate permissions +# - Docker installed and running +# - APIs enabled: run, sqladmin, redis, vpcaccess, secretmanager, cloudbuild +# +# Usage: +# ./deploy.sh [--project PROJECT_ID] [--region us-central1] +# ============================================================================ + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +PROJECT_ID="${GCP_PROJECT_ID:-$(gcloud config get-value project 2>/dev/null)}" +REGION="${GCP_REGION:-us-central1}" +PROJECT_NAME="finmind" +BACKEND_SERVICE="${PROJECT_NAME}-backend" +FRONTEND_SERVICE="${PROJECT_NAME}-frontend" +SQL_INSTANCE="${PROJECT_NAME}-postgres" +REDIS_INSTANCE="${PROJECT_NAME}-redis" +VPC_CONNECTOR="${PROJECT_NAME}-vpc-connector" +NETWORK="default" +BACKEND_DOCKERFILE="packages/backend/Dockerfile" +FRONTEND_DOCKERFILE="app/Dockerfile" + +# Parse CLI arguments +while [[ $# -gt 0 ]]; do + case $1 in + --project) PROJECT_ID="$2"; shift 2 ;; + --region) REGION="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +echo "=== FinMind GCP Cloud Run Deployment ===" +echo "Project: ${PROJECT_ID}" +echo "Region: ${REGION}" +echo "" + +gcloud config set project "${PROJECT_ID}" + +# --------------------------------------------------------------------------- +# Step 1: Enable required APIs +# --------------------------------------------------------------------------- +echo ">>> Step 1: Enabling required GCP APIs..." +gcloud services enable \ + run.googleapis.com \ + sqladmin.googleapis.com \ + redis.googleapis.com \ + vpcaccess.googleapis.com \ + secretmanager.googleapis.com \ + cloudbuild.googleapis.com \ + containerregistry.googleapis.com \ + --quiet + +# --------------------------------------------------------------------------- +# Step 2: Create Cloud SQL PostgreSQL instance +# --------------------------------------------------------------------------- +echo ">>> Step 2: Creating Cloud SQL PostgreSQL instance..." +if ! gcloud sql instances describe "${SQL_INSTANCE}" --quiet 2>/dev/null; then + gcloud sql instances create "${SQL_INSTANCE}" \ + --database-version=POSTGRES_15 \ + --tier=db-f1-micro \ + --region="${REGION}" \ + --storage-size=10GB \ + --storage-auto-increase \ + --backup-start-time=03:00 \ + --availability-type=zonal \ + --labels=app=finmind \ + --quiet + + # Create database and user + gcloud sql databases create finmind --instance="${SQL_INSTANCE}" --quiet + DB_PASSWORD=$(openssl rand -base64 32) + gcloud sql users create finmind \ + --instance="${SQL_INSTANCE}" \ + --password="${DB_PASSWORD}" \ + --quiet + + CLOUD_SQL_CONN="${PROJECT_ID}:${REGION}:${SQL_INSTANCE}" + DATABASE_URL="postgresql://finmind:${DB_PASSWORD}@/finmind?host=/cloudsql/${CLOUD_SQL_CONN}" + + echo " Cloud SQL instance created: ${SQL_INSTANCE}" + echo " Connection: ${CLOUD_SQL_CONN}" +else + CLOUD_SQL_CONN="${PROJECT_ID}:${REGION}:${SQL_INSTANCE}" + echo " Cloud SQL instance already exists: ${SQL_INSTANCE}" + echo " Ensure DATABASE_URL secret is set in Secret Manager." + DATABASE_URL="" +fi + +# --------------------------------------------------------------------------- +# Step 3: Create Memorystore Redis instance +# --------------------------------------------------------------------------- +echo ">>> Step 3: Creating Memorystore Redis instance..." +if ! gcloud redis instances describe "${REDIS_INSTANCE}" --region="${REGION}" --quiet 2>/dev/null; then + gcloud redis instances create "${REDIS_INSTANCE}" \ + --size=1 \ + --region="${REGION}" \ + --redis-version=redis_7_0 \ + --network="${NETWORK}" \ + --labels=app=finmind \ + --quiet + + REDIS_HOST=$(gcloud redis instances describe "${REDIS_INSTANCE}" \ + --region="${REGION}" --format='value(host)') + REDIS_PORT=$(gcloud redis instances describe "${REDIS_INSTANCE}" \ + --region="${REGION}" --format='value(port)') + REDIS_URL="redis://${REDIS_HOST}:${REDIS_PORT}/0" + + echo " Redis instance created: ${REDIS_INSTANCE} (${REDIS_HOST}:${REDIS_PORT})" +else + REDIS_HOST=$(gcloud redis instances describe "${REDIS_INSTANCE}" \ + --region="${REGION}" --format='value(host)' 2>/dev/null || echo "") + REDIS_PORT=$(gcloud redis instances describe "${REDIS_INSTANCE}" \ + --region="${REGION}" --format='value(port)' 2>/dev/null || echo "6379") + REDIS_URL="redis://${REDIS_HOST}:${REDIS_PORT}/0" + echo " Redis instance already exists: ${REDIS_INSTANCE}" +fi + +# --------------------------------------------------------------------------- +# Step 4: Create VPC connector (for Cloud SQL + Redis access) +# --------------------------------------------------------------------------- +echo ">>> Step 4: Creating Serverless VPC Access connector..." +if ! gcloud compute networks vpc-access connectors describe "${VPC_CONNECTOR}" \ + --region="${REGION}" --quiet 2>/dev/null; then + gcloud compute networks vpc-access connectors create "${VPC_CONNECTOR}" \ + --region="${REGION}" \ + --network="${NETWORK}" \ + --range="10.8.0.0/28" \ + --min-instances=2 \ + --max-instances=10 \ + --quiet + echo " VPC connector created: ${VPC_CONNECTOR}" +else + echo " VPC connector already exists: ${VPC_CONNECTOR}" +fi + +# --------------------------------------------------------------------------- +# Step 5: Store secrets in Secret Manager +# --------------------------------------------------------------------------- +echo ">>> Step 5: Storing secrets in Secret Manager..." + +store_secret() { + local name=$1 + local value=$2 + if ! gcloud secrets describe "${name}" --quiet 2>/dev/null; then + echo -n "${value}" | gcloud secrets create "${name}" \ + --data-file=- \ + --replication-policy=automatic \ + --labels=app=finmind \ + --quiet + echo " Created secret: ${name}" + else + echo " Secret already exists: ${name} (update manually if needed)" + fi +} + +if [[ -n "${DATABASE_URL}" ]]; then + store_secret "finmind-database-url" "${DATABASE_URL}" +fi +if [[ -n "${REDIS_URL}" ]]; then + store_secret "finmind-redis-url" "${REDIS_URL}" +fi +store_secret "finmind-jwt-secret" "$(openssl rand -base64 64)" +store_secret "finmind-gemini-api-key" "${GEMINI_API_KEY:-REPLACE_ME}" + +# Grant Cloud Run service account access to secrets +SA_EMAIL="${PROJECT_ID}@appspot.gserviceaccount.com" +for secret in finmind-database-url finmind-redis-url finmind-jwt-secret finmind-gemini-api-key; do + gcloud secrets add-iam-policy-binding "${secret}" \ + --member="serviceAccount:${SA_EMAIL}" \ + --role="roles/secretmanager.secretAccessor" \ + --quiet 2>/dev/null || true +done + +# --------------------------------------------------------------------------- +# Step 6: Build and push Docker images +# --------------------------------------------------------------------------- +echo ">>> Step 6: Building and pushing Docker images..." + +IMAGE_TAG="$(date +%Y%m%d-%H%M%S)" + +# Backend +echo " Building backend..." +docker build \ + -t "gcr.io/${PROJECT_ID}/${BACKEND_SERVICE}:${IMAGE_TAG}" \ + -t "gcr.io/${PROJECT_ID}/${BACKEND_SERVICE}:latest" \ + -f "${PROJECT_ROOT}/${BACKEND_DOCKERFILE}" \ + "${PROJECT_ROOT}/packages/backend" + +docker push "gcr.io/${PROJECT_ID}/${BACKEND_SERVICE}:${IMAGE_TAG}" +docker push "gcr.io/${PROJECT_ID}/${BACKEND_SERVICE}:latest" + +# Frontend +echo " Building frontend..." +docker build \ + -t "gcr.io/${PROJECT_ID}/${FRONTEND_SERVICE}:${IMAGE_TAG}" \ + -t "gcr.io/${PROJECT_ID}/${FRONTEND_SERVICE}:latest" \ + -f "${PROJECT_ROOT}/${FRONTEND_DOCKERFILE}" \ + "${PROJECT_ROOT}/app" + +docker push "gcr.io/${PROJECT_ID}/${FRONTEND_SERVICE}:${IMAGE_TAG}" +docker push "gcr.io/${PROJECT_ID}/${FRONTEND_SERVICE}:latest" + +# --------------------------------------------------------------------------- +# Step 7: Deploy backend to Cloud Run +# --------------------------------------------------------------------------- +echo ">>> Step 7: Deploying backend to Cloud Run..." +gcloud run deploy "${BACKEND_SERVICE}" \ + --image="gcr.io/${PROJECT_ID}/${BACKEND_SERVICE}:${IMAGE_TAG}" \ + --region="${REGION}" \ + --platform=managed \ + --port=8000 \ + --cpu=1 \ + --memory=512Mi \ + --min-instances=1 \ + --max-instances=10 \ + --concurrency=80 \ + --timeout=300s \ + --set-env-vars="LOG_LEVEL=info,GEMINI_MODEL=gemini-pro" \ + --set-secrets="DATABASE_URL=finmind-database-url:latest,REDIS_URL=finmind-redis-url:latest,JWT_SECRET=finmind-jwt-secret:latest,GEMINI_API_KEY=finmind-gemini-api-key:latest" \ + --add-cloudsql-instances="${CLOUD_SQL_CONN}" \ + --vpc-connector="${VPC_CONNECTOR}" \ + --ingress=all \ + --allow-unauthenticated \ + --labels=app=finmind,component=backend \ + --quiet + +BACKEND_URL=$(gcloud run services describe "${BACKEND_SERVICE}" \ + --region="${REGION}" --format='value(status.url)') +echo " Backend deployed: ${BACKEND_URL}" + +# --------------------------------------------------------------------------- +# Step 8: Deploy frontend to Cloud Run +# --------------------------------------------------------------------------- +echo ">>> Step 8: Deploying frontend to Cloud Run..." +gcloud run deploy "${FRONTEND_SERVICE}" \ + --image="gcr.io/${PROJECT_ID}/${FRONTEND_SERVICE}:${IMAGE_TAG}" \ + --region="${REGION}" \ + --platform=managed \ + --port=80 \ + --cpu=1 \ + --memory=256Mi \ + --min-instances=0 \ + --max-instances=5 \ + --concurrency=200 \ + --timeout=60s \ + --set-env-vars="VITE_API_URL=${BACKEND_URL}" \ + --ingress=all \ + --allow-unauthenticated \ + --labels=app=finmind,component=frontend \ + --quiet + +FRONTEND_URL=$(gcloud run services describe "${FRONTEND_SERVICE}" \ + --region="${REGION}" --format='value(status.url)') +echo " Frontend deployed: ${FRONTEND_URL}" + +# --------------------------------------------------------------------------- +# Step 9: Verify health +# --------------------------------------------------------------------------- +echo ">>> Step 9: Verifying deployment health..." +echo " Checking backend health endpoint..." +HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${BACKEND_URL}/health" || echo "000") +if [[ "${HTTP_STATUS}" == "200" ]]; then + echo " Backend health check: PASSED (HTTP ${HTTP_STATUS})" +else + echo " Backend health check: FAILED (HTTP ${HTTP_STATUS})" + echo " Check logs: gcloud run logs read ${BACKEND_SERVICE} --region=${REGION} --limit=50" +fi + +echo "" +echo "=== Deployment Complete ===" +echo "Backend: ${BACKEND_URL}" +echo "Frontend: ${FRONTEND_URL}" +echo "" +echo "Useful commands:" +echo " gcloud run logs read ${BACKEND_SERVICE} --region=${REGION} --limit=50" +echo " gcloud run services describe ${BACKEND_SERVICE} --region=${REGION}" +echo " gcloud run services describe ${FRONTEND_SERVICE} --region=${REGION}" diff --git a/deploy/helm/finmind/Chart.yaml b/deploy/helm/finmind/Chart.yaml new file mode 100644 index 00000000..81bcb548 --- /dev/null +++ b/deploy/helm/finmind/Chart.yaml @@ -0,0 +1,19 @@ +apiVersion: v2 +name: finmind +description: FinMind — AI-powered personal finance platform with Flask backend, React frontend, PostgreSQL, and Redis +type: application +version: 0.1.0 +appVersion: "1.0.0" +keywords: + - finmind + - finance + - flask + - react + - postgresql + - redis +maintainers: + - name: FinMind Team + url: https://github.com/rohitdash08/FinMind +home: https://github.com/rohitdash08/FinMind +sources: + - https://github.com/rohitdash08/FinMind diff --git a/deploy/helm/finmind/templates/NOTES.txt b/deploy/helm/finmind/templates/NOTES.txt new file mode 100644 index 00000000..113c84ed --- /dev/null +++ b/deploy/helm/finmind/templates/NOTES.txt @@ -0,0 +1,72 @@ + +======================================================================= + FinMind has been deployed successfully! +======================================================================= + +Namespace: {{ include "finmind.namespace" . }} + +Components deployed: + - Backend (Flask/Gunicorn) : {{ include "finmind.backend.serviceName" . }}:{{ .Values.backend.service.port }} + - Frontend (React/Nginx) : {{ include "finmind.frontend.serviceName" . }}:{{ .Values.frontend.service.port }} + - PostgreSQL 16 : {{ include "finmind.postgresql.serviceName" . }}:{{ .Values.postgresql.service.port }} + - Redis 7 : {{ include "finmind.redis.serviceName" . }}:{{ .Values.redis.service.port }} + +{{- if .Values.ingress.enabled }} + +Ingress: +{{- range .Values.ingress.hosts }} + URL: https://{{ .host }} +{{- end }} +{{- if .Values.ingress.tls }} + TLS: Enabled (cert-manager will provision certificates) +{{- end }} +{{- end }} + +{{- if .Values.backend.autoscaling.enabled }} + +Autoscaling (Backend): + Min replicas: {{ .Values.backend.autoscaling.minReplicas }} + Max replicas: {{ .Values.backend.autoscaling.maxReplicas }} + CPU target: {{ .Values.backend.autoscaling.targetCPUUtilizationPercentage }}% +{{- end }} + +{{- if .Values.frontend.autoscaling.enabled }} + +Autoscaling (Frontend): + Min replicas: {{ .Values.frontend.autoscaling.minReplicas }} + Max replicas: {{ .Values.frontend.autoscaling.maxReplicas }} + CPU target: {{ .Values.frontend.autoscaling.targetCPUUtilizationPercentage }}% +{{- end }} + +----------------------------------------------------------------------- + IMPORTANT: Update secrets before production use! +----------------------------------------------------------------------- + + Override secrets at install time: + + helm upgrade --install finmind ./deploy/helm/finmind \ + --set secrets.postgresPassword=$(echo -n 'YOUR_PASSWORD' | base64) \ + --set secrets.jwtSecret=$(echo -n 'YOUR_JWT_SECRET' | base64) \ + --set secrets.geminiApiKey=$(echo -n 'YOUR_GEMINI_KEY' | base64) \ + --set secrets.openaiApiKey=$(echo -n 'YOUR_OPENAI_KEY' | base64) + + Or use a values override file: + + helm upgrade --install finmind ./deploy/helm/finmind \ + -f my-production-values.yaml + +----------------------------------------------------------------------- + Useful commands +----------------------------------------------------------------------- + + # Check pod status + kubectl get pods -n {{ include "finmind.namespace" . }} + + # View backend logs + kubectl logs -n {{ include "finmind.namespace" . }} -l app.kubernetes.io/component=backend -f + + # Port-forward frontend locally + kubectl port-forward -n {{ include "finmind.namespace" . }} svc/{{ include "finmind.frontend.serviceName" . }} 8080:{{ .Values.frontend.service.port }} + + # Port-forward backend locally + kubectl port-forward -n {{ include "finmind.namespace" . }} svc/{{ include "finmind.backend.serviceName" . }} 8000:{{ .Values.backend.service.port }} diff --git a/deploy/helm/finmind/templates/_helpers.tpl b/deploy/helm/finmind/templates/_helpers.tpl new file mode 100644 index 00000000..e55f6660 --- /dev/null +++ b/deploy/helm/finmind/templates/_helpers.tpl @@ -0,0 +1,173 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "finmind.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +*/}} +{{- define "finmind.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "finmind.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "finmind.labels" -}} +helm.sh/chart: {{ include "finmind.chart" . }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: finmind +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +{{- end }} + +{{/* +Backend labels +*/}} +{{- define "finmind.backend.labels" -}} +{{ include "finmind.labels" . }} +app.kubernetes.io/name: {{ include "finmind.name" . }}-backend +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: backend +{{- end }} + +{{/* +Backend selector labels +*/}} +{{- define "finmind.backend.selectorLabels" -}} +app.kubernetes.io/name: {{ include "finmind.name" . }}-backend +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Frontend labels +*/}} +{{- define "finmind.frontend.labels" -}} +{{ include "finmind.labels" . }} +app.kubernetes.io/name: {{ include "finmind.name" . }}-frontend +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: frontend +{{- end }} + +{{/* +Frontend selector labels +*/}} +{{- define "finmind.frontend.selectorLabels" -}} +app.kubernetes.io/name: {{ include "finmind.name" . }}-frontend +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +PostgreSQL labels +*/}} +{{- define "finmind.postgresql.labels" -}} +{{ include "finmind.labels" . }} +app.kubernetes.io/name: {{ include "finmind.name" . }}-postgresql +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: database +{{- end }} + +{{/* +PostgreSQL selector labels +*/}} +{{- define "finmind.postgresql.selectorLabels" -}} +app.kubernetes.io/name: {{ include "finmind.name" . }}-postgresql +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Redis labels +*/}} +{{- define "finmind.redis.labels" -}} +{{ include "finmind.labels" . }} +app.kubernetes.io/name: {{ include "finmind.name" . }}-redis +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: cache +{{- end }} + +{{/* +Redis selector labels +*/}} +{{- define "finmind.redis.selectorLabels" -}} +app.kubernetes.io/name: {{ include "finmind.name" . }}-redis +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Namespace helper +*/}} +{{- define "finmind.namespace" -}} +{{- default .Release.Namespace .Values.global.namespace }} +{{- end }} + +{{/* +Backend service name +*/}} +{{- define "finmind.backend.serviceName" -}} +{{- printf "%s-backend" (include "finmind.fullname" .) }} +{{- end }} + +{{/* +Frontend service name +*/}} +{{- define "finmind.frontend.serviceName" -}} +{{- printf "%s-frontend" (include "finmind.fullname" .) }} +{{- end }} + +{{/* +PostgreSQL service name +*/}} +{{- define "finmind.postgresql.serviceName" -}} +{{- printf "%s-postgresql" (include "finmind.fullname" .) }} +{{- end }} + +{{/* +Redis service name +*/}} +{{- define "finmind.redis.serviceName" -}} +{{- printf "%s-redis" (include "finmind.fullname" .) }} +{{- end }} + +{{/* +Secret name +*/}} +{{- define "finmind.secretName" -}} +{{- printf "%s-secret" (include "finmind.fullname" .) }} +{{- end }} + +{{/* +ConfigMap name +*/}} +{{- define "finmind.configMapName" -}} +{{- printf "%s-config" (include "finmind.fullname" .) }} +{{- end }} + +{{/* +Image pull secrets +*/}} +{{- define "finmind.imagePullSecrets" -}} +{{- with .Values.imagePullSecrets }} +imagePullSecrets: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- end }} diff --git a/deploy/helm/finmind/templates/backend-deployment.yaml b/deploy/helm/finmind/templates/backend-deployment.yaml new file mode 100644 index 00000000..8382dcc7 --- /dev/null +++ b/deploy/helm/finmind/templates/backend-deployment.yaml @@ -0,0 +1,137 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "finmind.backend.serviceName" . }} + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.backend.labels" . | nindent 4 }} +spec: + {{- if not .Values.backend.autoscaling.enabled }} + replicas: {{ .Values.backend.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "finmind.backend.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "finmind.backend.labels" . | nindent 8 }} + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8000" + prometheus.io/path: "/metrics" + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + spec: + {{- include "finmind.imagePullSecrets" . | nindent 6 }} + initContainers: + - name: wait-for-postgres + image: busybox:1.36 + command: + - sh + - -c + - | + until nc -z {{ include "finmind.postgresql.serviceName" . }} {{ .Values.postgresql.service.port }}; do + echo "Waiting for PostgreSQL..." + sleep 2 + done + - name: wait-for-redis + image: busybox:1.36 + command: + - sh + - -c + - | + until nc -z {{ include "finmind.redis.serviceName" . }} {{ .Values.redis.service.port }}; do + echo "Waiting for Redis..." + sleep 2 + done + containers: + - name: backend + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.backend.service.port }} + protocol: TCP + command: + - sh + - -c + - | + python -m flask --app wsgi:app init-db && \ + export PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus_multiproc && \ + rm -rf $PROMETHEUS_MULTIPROC_DIR && \ + mkdir -p $PROMETHEUS_MULTIPROC_DIR && \ + gunicorn --workers=2 --threads=4 --bind 0.0.0.0:8000 wsgi:app + env: + # -- Secrets + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: {{ include "finmind.secretName" . }} + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "finmind.secretName" . }} + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + valueFrom: + secretKeyRef: + name: {{ include "finmind.secretName" . }} + key: POSTGRES_DB + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "finmind.secretName" . }} + key: JWT_SECRET + {{- if .Values.secrets.geminiApiKey }} + - name: GEMINI_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "finmind.secretName" . }} + key: GEMINI_API_KEY + {{- end }} + {{- if .Values.secrets.openaiApiKey }} + - name: OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "finmind.secretName" . }} + key: OPENAI_API_KEY + {{- end }} + # -- ConfigMap values + - name: LOG_LEVEL + valueFrom: + configMapKeyRef: + name: {{ include "finmind.configMapName" . }} + key: LOG_LEVEL + - name: GEMINI_MODEL + valueFrom: + configMapKeyRef: + name: {{ include "finmind.configMapName" . }} + key: GEMINI_MODEL + - name: REDIS_URL + valueFrom: + configMapKeyRef: + name: {{ include "finmind.configMapName" . }} + key: REDIS_URL + # -- Constructed DATABASE_URL + - name: DATABASE_URL + value: "postgresql://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@{{ include "finmind.postgresql.serviceName" . }}:{{ .Values.postgresql.service.port }}/$(POSTGRES_DB)" + livenessProbe: + httpGet: + path: {{ .Values.backend.probes.liveness.path }} + port: {{ .Values.backend.probes.liveness.port }} + initialDelaySeconds: {{ .Values.backend.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.backend.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.backend.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.backend.probes.liveness.failureThreshold }} + readinessProbe: + httpGet: + path: {{ .Values.backend.probes.readiness.path }} + port: {{ .Values.backend.probes.readiness.port }} + initialDelaySeconds: {{ .Values.backend.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.backend.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.backend.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.backend.probes.readiness.failureThreshold }} + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} diff --git a/deploy/helm/finmind/templates/backend-service.yaml b/deploy/helm/finmind/templates/backend-service.yaml new file mode 100644 index 00000000..adc66bf5 --- /dev/null +++ b/deploy/helm/finmind/templates/backend-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "finmind.backend.serviceName" . }} + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.backend.labels" . | nindent 4 }} +spec: + type: {{ .Values.backend.service.type }} + ports: + - port: {{ .Values.backend.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "finmind.backend.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/finmind/templates/configmap.yaml b/deploy/helm/finmind/templates/configmap.yaml new file mode 100644 index 00000000..5874026d --- /dev/null +++ b/deploy/helm/finmind/templates/configmap.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "finmind.configMapName" . }} + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.labels" . | nindent 4 }} +data: + LOG_LEVEL: {{ .Values.config.logLevel | quote }} + GEMINI_MODEL: {{ .Values.config.geminiModel | quote }} + REDIS_URL: {{ .Values.config.redisUrl | quote }} + DATABASE_URL: "postgresql://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@{{ include "finmind.postgresql.serviceName" . }}:{{ .Values.postgresql.service.port }}/$(POSTGRES_DB)" diff --git a/deploy/helm/finmind/templates/frontend-deployment.yaml b/deploy/helm/finmind/templates/frontend-deployment.yaml new file mode 100644 index 00000000..cdd2e739 --- /dev/null +++ b/deploy/helm/finmind/templates/frontend-deployment.yaml @@ -0,0 +1,49 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "finmind.frontend.serviceName" . }} + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.frontend.labels" . | nindent 4 }} +spec: + {{- if not .Values.frontend.autoscaling.enabled }} + replicas: {{ .Values.frontend.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "finmind.frontend.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "finmind.frontend.labels" . | nindent 8 }} + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "80" + spec: + {{- include "finmind.imagePullSecrets" . | nindent 6 }} + containers: + - name: frontend + image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}" + imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: {{ .Values.frontend.probes.liveness.path }} + port: {{ .Values.frontend.probes.liveness.port }} + initialDelaySeconds: {{ .Values.frontend.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.frontend.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.frontend.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.frontend.probes.liveness.failureThreshold }} + readinessProbe: + httpGet: + path: {{ .Values.frontend.probes.readiness.path }} + port: {{ .Values.frontend.probes.readiness.port }} + initialDelaySeconds: {{ .Values.frontend.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.frontend.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.frontend.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.frontend.probes.readiness.failureThreshold }} + resources: + {{- toYaml .Values.frontend.resources | nindent 12 }} diff --git a/deploy/helm/finmind/templates/frontend-service.yaml b/deploy/helm/finmind/templates/frontend-service.yaml new file mode 100644 index 00000000..2e582a30 --- /dev/null +++ b/deploy/helm/finmind/templates/frontend-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "finmind.frontend.serviceName" . }} + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.frontend.labels" . | nindent 4 }} +spec: + type: {{ .Values.frontend.service.type }} + ports: + - port: {{ .Values.frontend.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "finmind.frontend.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/finmind/templates/hpa.yaml b/deploy/helm/finmind/templates/hpa.yaml new file mode 100644 index 00000000..603c8508 --- /dev/null +++ b/deploy/helm/finmind/templates/hpa.yaml @@ -0,0 +1,67 @@ +{{- if .Values.backend.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "finmind.backend.serviceName" . }} + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.backend.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "finmind.backend.serviceName" . }} + minReplicas: {{ .Values.backend.autoscaling.minReplicas }} + maxReplicas: {{ .Values.backend.autoscaling.maxReplicas }} + metrics: + {{- if .Values.backend.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.backend.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.backend.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.backend.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} +--- +{{- if .Values.frontend.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "finmind.frontend.serviceName" . }} + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.frontend.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "finmind.frontend.serviceName" . }} + minReplicas: {{ .Values.frontend.autoscaling.minReplicas }} + maxReplicas: {{ .Values.frontend.autoscaling.maxReplicas }} + metrics: + {{- if .Values.frontend.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.frontend.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.frontend.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.frontend.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/deploy/helm/finmind/templates/ingress.yaml b/deploy/helm/finmind/templates/ingress.yaml new file mode 100644 index 00000000..5cc5fa73 --- /dev/null +++ b/deploy/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" . }} + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - secretName: {{ .secretName }} + hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + {{- if eq .service "backend" }} + name: {{ include "finmind.backend.serviceName" $ }} + port: + number: {{ $.Values.backend.service.port }} + {{- else }} + name: {{ include "finmind.frontend.serviceName" $ }} + port: + number: {{ $.Values.frontend.service.port }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/helm/finmind/templates/namespace.yaml b/deploy/helm/finmind/templates/namespace.yaml new file mode 100644 index 00000000..2953f0a5 --- /dev/null +++ b/deploy/helm/finmind/templates/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.labels" . | nindent 4 }} diff --git a/deploy/helm/finmind/templates/postgres-deployment.yaml b/deploy/helm/finmind/templates/postgres-deployment.yaml new file mode 100644 index 00000000..ad8c91a3 --- /dev/null +++ b/deploy/helm/finmind/templates/postgres-deployment.yaml @@ -0,0 +1,86 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "finmind.postgresql.serviceName" . }} + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.postgresql.labels" . | nindent 4 }} +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + {{- include "finmind.postgresql.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "finmind.postgresql.labels" . | nindent 8 }} + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9187" + spec: + {{- include "finmind.imagePullSecrets" . | nindent 6 }} + containers: + - name: postgresql + image: "{{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }}" + imagePullPolicy: {{ .Values.postgresql.image.pullPolicy }} + ports: + - name: postgres + containerPort: 5432 + protocol: TCP + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: {{ include "finmind.secretName" . }} + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "finmind.secretName" . }} + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + valueFrom: + secretKeyRef: + name: {{ include "finmind.secretName" . }} + key: POSTGRES_DB + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + livenessProbe: + exec: + command: + - pg_isready + - -U + - $(POSTGRES_USER) + - -d + - $(POSTGRES_DB) + initialDelaySeconds: 30 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + exec: + command: + - pg_isready + - -U + - $(POSTGRES_USER) + - -d + - $(POSTGRES_DB) + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + {{- toYaml .Values.postgresql.resources | nindent 12 }} + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + volumes: + - name: postgres-data + {{- if .Values.postgresql.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "finmind.postgresql.serviceName" . }}-pvc + {{- else }} + emptyDir: {} + {{- end }} diff --git a/deploy/helm/finmind/templates/postgres-pvc.yaml b/deploy/helm/finmind/templates/postgres-pvc.yaml new file mode 100644 index 00000000..cc2ddf65 --- /dev/null +++ b/deploy/helm/finmind/templates/postgres-pvc.yaml @@ -0,0 +1,18 @@ +{{- if .Values.postgresql.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "finmind.postgresql.serviceName" . }}-pvc + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.postgresql.labels" . | nindent 4 }} +spec: + accessModes: + - {{ .Values.postgresql.persistence.accessMode }} + {{- if .Values.postgresql.persistence.storageClass }} + storageClassName: {{ .Values.postgresql.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.postgresql.persistence.size }} +{{- end }} diff --git a/deploy/helm/finmind/templates/postgres-service.yaml b/deploy/helm/finmind/templates/postgres-service.yaml new file mode 100644 index 00000000..860fe190 --- /dev/null +++ b/deploy/helm/finmind/templates/postgres-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "finmind.postgresql.serviceName" . }} + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.postgresql.labels" . | nindent 4 }} +spec: + type: {{ .Values.postgresql.service.type }} + ports: + - port: {{ .Values.postgresql.service.port }} + targetPort: postgres + protocol: TCP + name: postgres + selector: + {{- include "finmind.postgresql.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/finmind/templates/redis-deployment.yaml b/deploy/helm/finmind/templates/redis-deployment.yaml new file mode 100644 index 00000000..9e088544 --- /dev/null +++ b/deploy/helm/finmind/templates/redis-deployment.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "finmind.redis.serviceName" . }} + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.redis.labels" . | nindent 4 }} +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + {{- include "finmind.redis.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "finmind.redis.labels" . | nindent 8 }} + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9121" + spec: + {{- include "finmind.imagePullSecrets" . | nindent 6 }} + containers: + - name: redis + image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}" + imagePullPolicy: {{ .Values.redis.image.pullPolicy }} + ports: + - name: redis + containerPort: 6379 + protocol: TCP + command: + - redis-server + - --appendonly + - "yes" + - --maxmemory + - "128mb" + - --maxmemory-policy + - allkeys-lru + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 15 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + {{- toYaml .Values.redis.resources | nindent 12 }} diff --git a/deploy/helm/finmind/templates/redis-service.yaml b/deploy/helm/finmind/templates/redis-service.yaml new file mode 100644 index 00000000..b6ef87d3 --- /dev/null +++ b/deploy/helm/finmind/templates/redis-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "finmind.redis.serviceName" . }} + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.redis.labels" . | nindent 4 }} +spec: + type: {{ .Values.redis.service.type }} + ports: + - port: {{ .Values.redis.service.port }} + targetPort: redis + protocol: TCP + name: redis + selector: + {{- include "finmind.redis.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/finmind/templates/secret.yaml b/deploy/helm/finmind/templates/secret.yaml new file mode 100644 index 00000000..02c916bb --- /dev/null +++ b/deploy/helm/finmind/templates/secret.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "finmind.secretName" . }} + namespace: {{ include "finmind.namespace" . }} + labels: + {{- include "finmind.labels" . | nindent 4 }} +type: Opaque +data: + POSTGRES_USER: {{ .Values.secrets.postgresUser | quote }} + POSTGRES_PASSWORD: {{ .Values.secrets.postgresPassword | quote }} + POSTGRES_DB: {{ .Values.secrets.postgresDb | quote }} + JWT_SECRET: {{ .Values.secrets.jwtSecret | quote }} + {{- if .Values.secrets.geminiApiKey }} + GEMINI_API_KEY: {{ .Values.secrets.geminiApiKey | quote }} + {{- end }} + {{- if .Values.secrets.openaiApiKey }} + OPENAI_API_KEY: {{ .Values.secrets.openaiApiKey | quote }} + {{- end }} diff --git a/deploy/helm/finmind/values.yaml b/deploy/helm/finmind/values.yaml new file mode 100644 index 00000000..e05fb2f4 --- /dev/null +++ b/deploy/helm/finmind/values.yaml @@ -0,0 +1,188 @@ +# -- Global overrides +global: + # -- Namespace for all FinMind resources + namespace: finmind + +# ----------------------------------------------------------------------------- +# Backend (Flask / Gunicorn) +# ----------------------------------------------------------------------------- +backend: + replicaCount: 2 + image: + repository: ghcr.io/rohitdash08/finmind-backend + tag: latest + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 8000 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: "1" + memory: 512Mi + probes: + liveness: + path: /health + port: 8000 + initialDelaySeconds: 30 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + readiness: + path: /health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 8 + targetCPUUtilizationPercentage: 70 + targetMemoryUtilizationPercentage: 80 + +# ----------------------------------------------------------------------------- +# Frontend (React / Nginx) +# ----------------------------------------------------------------------------- +frontend: + replicaCount: 2 + image: + repository: ghcr.io/rohitdash08/finmind-frontend + tag: latest + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 80 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + probes: + liveness: + path: / + port: 80 + initialDelaySeconds: 10 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readiness: + path: / + port: 80 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 6 + targetCPUUtilizationPercentage: 75 + targetMemoryUtilizationPercentage: 80 + +# ----------------------------------------------------------------------------- +# PostgreSQL +# ----------------------------------------------------------------------------- +postgresql: + image: + repository: postgres + tag: "16" + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 5432 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + persistence: + enabled: true + storageClass: "" + accessMode: ReadWriteOnce + size: 10Gi + +# ----------------------------------------------------------------------------- +# Redis +# ----------------------------------------------------------------------------- +redis: + image: + repository: redis + tag: "7" + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 6379 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + +# ----------------------------------------------------------------------------- +# Ingress +# ----------------------------------------------------------------------------- +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/proxy-body-size: "10m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "60" + nginx.ingress.kubernetes.io/proxy-send-timeout: "60" + hosts: + - host: finmind.example.com + paths: + - path: / + pathType: Prefix + service: frontend + - path: /api + pathType: Prefix + service: backend + tls: + - secretName: finmind-tls + hosts: + - finmind.example.com + +# ----------------------------------------------------------------------------- +# Secrets (base64-encoded defaults — override in production!) +# ----------------------------------------------------------------------------- +secrets: + postgresUser: ZmlubWluZA== # finmind + postgresPassword: Y2hhbmdlbWU= # changeme + postgresDb: ZmlubWluZA== # finmind + jwtSecret: c3VwZXItc2VjcmV0LWp3dA== # super-secret-jwt + geminiApiKey: "" + openaiApiKey: "" + +# ----------------------------------------------------------------------------- +# ConfigMap +# ----------------------------------------------------------------------------- +config: + logLevel: INFO + geminiModel: gemini-2.0-flash + redisUrl: redis://finmind-redis:6379/0 + # DATABASE_URL is constructed from secret values at runtime + +# ----------------------------------------------------------------------------- +# Image pull secrets (if using private registries) +# ----------------------------------------------------------------------------- +imagePullSecrets: [] + +# ----------------------------------------------------------------------------- +# Service Account +# ----------------------------------------------------------------------------- +serviceAccount: + create: false + name: "" + annotations: {} diff --git a/deploy/heroku/Procfile b/deploy/heroku/Procfile new file mode 100644 index 00000000..e1d0cbb5 --- /dev/null +++ b/deploy/heroku/Procfile @@ -0,0 +1,3 @@ +web: gunicorn --bind 0.0.0.0:$PORT --workers 4 --timeout 120 --access-logfile - --error-logfile - "app:create_app()" +worker: celery -A app.celery worker --loglevel=info +release: echo "Release phase complete" diff --git a/deploy/heroku/app.json b/deploy/heroku/app.json new file mode 100644 index 00000000..623a1eb3 --- /dev/null +++ b/deploy/heroku/app.json @@ -0,0 +1,78 @@ +{ + "name": "FinMind", + "description": "AI-powered personal finance management platform with intelligent budgeting, investment tracking, and financial insights.", + "keywords": [ + "finance", + "ai", + "budgeting", + "investment", + "flask", + "react" + ], + "repository": "https://github.com/your-org/FinMind", + "logo": "", + "stack": "container", + "formation": { + "web": { + "quantity": 1, + "size": "standard-1x" + } + }, + "addons": [ + { + "plan": "heroku-postgresql:essential-0", + "as": "DATABASE" + }, + { + "plan": "heroku-redis:mini", + "as": "REDIS" + } + ], + "env": { + "JWT_SECRET": { + "description": "Secret key used for signing JWT tokens. Generate with: openssl rand -hex 64", + "generator": "secret", + "required": true + }, + "GEMINI_API_KEY": { + "description": "Google Gemini API key for AI features", + "required": true + }, + "GEMINI_MODEL": { + "description": "Gemini model identifier", + "value": "gemini-pro", + "required": false + }, + "LOG_LEVEL": { + "description": "Application log level (DEBUG, INFO, WARNING, ERROR)", + "value": "INFO", + "required": false + }, + "PYTHONUNBUFFERED": { + "description": "Ensure Python output is sent straight to terminal", + "value": "1", + "required": false + } + }, + "buildpacks": [], + "environments": { + "review": { + "addons": [ + "heroku-postgresql:essential-0", + "heroku-redis:mini" + ], + "env": { + "LOG_LEVEL": "DEBUG" + } + }, + "staging": { + "addons": [ + "heroku-postgresql:essential-0", + "heroku-redis:mini" + ] + } + }, + "scripts": { + "postdeploy": "echo 'FinMind deployed successfully'" + } +} diff --git a/deploy/heroku/heroku.yml b/deploy/heroku/heroku.yml new file mode 100644 index 00000000..37c3902e --- /dev/null +++ b/deploy/heroku/heroku.yml @@ -0,0 +1,35 @@ +setup: + addons: + - plan: heroku-postgresql:essential-0 + as: DATABASE + - plan: heroku-redis:mini + as: REDIS + config: + LOG_LEVEL: INFO + GEMINI_MODEL: gemini-pro + PYTHONUNBUFFERED: "1" + +build: + docker: + web: packages/backend/Dockerfile + frontend: app/Dockerfile + config: + web: {} + frontend: {} + +run: + web: + command: + - gunicorn + - --bind=0.0.0.0:8000 + - --workers=4 + - --timeout=120 + - --access-logfile=- + - --error-logfile=- + - "app:create_app()" + image: web + +release: + command: + - echo "Running release phase" + image: web diff --git a/deploy/netlify/netlify.toml b/deploy/netlify/netlify.toml new file mode 100644 index 00000000..cb9c1fe5 --- /dev/null +++ b/deploy/netlify/netlify.toml @@ -0,0 +1,86 @@ +# ============================================================================ +# FinMind — Netlify Configuration +# ============================================================================ +# Deploys the React/Vite frontend as a static site on Netlify. +# +# Setup: +# 1. Connect repo to Netlify +# 2. Set VITE_API_URL environment variable in Netlify dashboard +# 3. Deploy +# ============================================================================ + +[build] + # Base directory relative to repo root + base = "app" + + # Build command + command = "npm run build" + + # Output directory (relative to base) + publish = "dist" + + # Node.js version + environment = { NODE_VERSION = "20" } + +[build.environment] + # Placeholder — set actual value in Netlify dashboard or via CLI + VITE_API_URL = "https://api.finmind.example.com" + +# --------------------------------------------------------------------------- +# SPA Routing — redirect all paths to index.html for client-side routing +# --------------------------------------------------------------------------- +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 + +# --------------------------------------------------------------------------- +# Security Headers +# --------------------------------------------------------------------------- +[[headers]] + for = "/*" + [headers.values] + X-Frame-Options = "DENY" + X-Content-Type-Options = "nosniff" + X-XSS-Protection = "1; mode=block" + Referrer-Policy = "strict-origin-when-cross-origin" + Permissions-Policy = "camera=(), microphone=(), geolocation=()" + +# --------------------------------------------------------------------------- +# Cache static assets aggressively +# --------------------------------------------------------------------------- +[[headers]] + for = "/assets/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +[[headers]] + for = "*.js" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +[[headers]] + for = "*.css" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +# --------------------------------------------------------------------------- +# Preview / Branch Deploys +# --------------------------------------------------------------------------- +[context.deploy-preview] + command = "npm run build" + +[context.deploy-preview.environment] + VITE_API_URL = "https://api-staging.finmind.example.com" + +[context.branch-deploy] + command = "npm run build" + +# --------------------------------------------------------------------------- +# Dev settings +# --------------------------------------------------------------------------- +[dev] + command = "npm run dev" + port = 5173 + targetPort = 5173 + autoLaunch = false diff --git a/deploy/railway/railway.json b/deploy/railway/railway.json new file mode 100644 index 00000000..b8342b27 --- /dev/null +++ b/deploy/railway/railway.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "DOCKERFILE" + }, + "deploy": { + "numReplicas": 1, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + }, + "services": { + "backend": { + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "packages/backend/Dockerfile", + "buildContext": "." + }, + "deploy": { + "numReplicas": 1, + "startCommand": "gunicorn --bind 0.0.0.0:8000 --workers 4 --timeout 120 app:create_app()", + "healthcheckPath": "/health", + "healthcheckTimeout": 30, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 5 + }, + "networking": { + "servicePort": 8000 + }, + "variables": { + "DATABASE_URL": "${{Postgres.DATABASE_URL}}", + "REDIS_URL": "${{Redis.REDIS_URL}}", + "JWT_SECRET": { + "description": "Secret key for JWT token signing", + "required": true + }, + "GEMINI_API_KEY": { + "description": "Google Gemini API key", + "required": true + }, + "GEMINI_MODEL": { + "default": "gemini-pro", + "description": "Gemini model to use" + }, + "LOG_LEVEL": { + "default": "INFO", + "description": "Application log level" + }, + "PORT": "8000", + "PYTHONUNBUFFERED": "1" + } + }, + "frontend": { + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "app/Dockerfile", + "buildContext": "app" + }, + "deploy": { + "numReplicas": 1, + "healthcheckPath": "/", + "healthcheckTimeout": 15, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + }, + "networking": { + "servicePort": 80 + }, + "variables": { + "VITE_API_URL": { + "description": "Backend API URL (set to backend service URL)", + "required": true + } + } + }, + "Postgres": { + "plugin": "postgresql", + "deploy": { + "numReplicas": 1 + } + }, + "Redis": { + "plugin": "redis", + "deploy": { + "numReplicas": 1 + } + } + } +} diff --git a/deploy/railway/railway.toml b/deploy/railway/railway.toml new file mode 100644 index 00000000..4c4426f9 --- /dev/null +++ b/deploy/railway/railway.toml @@ -0,0 +1,17 @@ +[build] +builder = "DOCKERFILE" + +[build.backend] +dockerfilePath = "packages/backend/Dockerfile" +buildContext = "." + +[build.frontend] +dockerfilePath = "app/Dockerfile" +buildContext = "app" + +[deploy] +numReplicas = 1 +healthcheckPath = "/health" +healthcheckTimeout = 30 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 5 diff --git a/deploy/render/render.yaml b/deploy/render/render.yaml new file mode 100644 index 00000000..bcb5f4ba --- /dev/null +++ b/deploy/render/render.yaml @@ -0,0 +1,86 @@ +# Render Blueprint — FinMind +# Deploy: https://render.com/deploy?repo=https://github.com/your-org/FinMind + +services: + # ── Backend API ────────────────────────────────────────────── + - type: web + name: finmind-backend + runtime: docker + dockerfilePath: packages/backend/Dockerfile + dockerContext: . + region: oregon + plan: starter + branch: main + numInstances: 1 + healthCheckPath: /health + envVars: + - key: DATABASE_URL + fromDatabase: + name: finmind-db + property: connectionString + - key: REDIS_URL + fromService: + name: finmind-redis + type: redis + property: connectionString + - key: JWT_SECRET + generateValue: true + - key: GEMINI_API_KEY + sync: false + - key: GEMINI_MODEL + value: gemini-pro + - key: LOG_LEVEL + value: INFO + - key: PORT + value: "8000" + - key: PYTHONUNBUFFERED + value: "1" + scaling: + minInstances: 1 + maxInstances: 3 + targetMemoryPercent: 75 + targetCPUPercent: 70 + autoDeploy: true + buildFilter: + paths: + - packages/backend/** + + # ── Frontend Static Site ───────────────────────────────────── + - type: web + name: finmind-frontend + runtime: docker + dockerfilePath: app/Dockerfile + dockerContext: app + region: oregon + plan: starter + branch: main + numInstances: 1 + healthCheckPath: / + envVars: + - key: VITE_API_URL + fromService: + name: finmind-backend + type: web + property: hostport + autoDeploy: true + buildFilter: + paths: + - app/** + + # ── Redis ──────────────────────────────────────────────────── + - type: redis + name: finmind-redis + region: oregon + plan: starter + maxmemoryPolicy: allkeys-lru + ipAllowList: [] # only accessible from internal network + +# ── Managed PostgreSQL ─────────────────────────────────────── +databases: + - name: finmind-db + region: oregon + plan: starter + databaseName: finmind + user: finmind + ipAllowList: [] # only accessible from internal network + postgresMajorVersion: "16" diff --git a/deploy/tilt/backend.yaml b/deploy/tilt/backend.yaml new file mode 100644 index 00000000..2ea3b74b --- /dev/null +++ b/deploy/tilt/backend.yaml @@ -0,0 +1,105 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend + namespace: finmind + labels: + app: backend +spec: + replicas: 1 + selector: + matchLabels: + app: backend + template: + metadata: + labels: + app: backend + spec: + containers: + - name: backend + image: finmind-backend + ports: + - containerPort: 8000 + command: + - sh + - -c + - "python -m flask --app wsgi:app init-db && gunicorn --reload --workers=1 --threads=2 --bind 0.0.0.0:8000 wsgi:app" + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: finmind-secrets + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: finmind-secrets + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + valueFrom: + secretKeyRef: + name: finmind-secrets + key: POSTGRES_DB + - name: DATABASE_URL + value: "postgresql://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@postgres:5432/$(POSTGRES_DB)" + - name: REDIS_URL + valueFrom: + configMapKeyRef: + name: finmind-config + key: REDIS_URL + - name: LOG_LEVEL + valueFrom: + configMapKeyRef: + name: finmind-config + key: LOG_LEVEL + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: finmind-secrets + key: JWT_SECRET + - name: GEMINI_API_KEY + valueFrom: + secretKeyRef: + name: finmind-secrets + key: GEMINI_API_KEY + - name: OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: finmind-secrets + key: OPENAI_API_KEY + - name: GEMINI_MODEL + valueFrom: + configMapKeyRef: + name: finmind-config + key: GEMINI_MODEL + - name: FLASK_ENV + valueFrom: + configMapKeyRef: + name: finmind-config + key: FLASK_ENV + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: backend + namespace: finmind + labels: + app: backend +spec: + selector: + app: backend + ports: + - port: 8000 + targetPort: 8000 diff --git a/deploy/tilt/configmap.yaml b/deploy/tilt/configmap.yaml new file mode 100644 index 00000000..feb38a03 --- /dev/null +++ b/deploy/tilt/configmap.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: finmind-config + namespace: finmind + labels: + app: finmind +data: + LOG_LEVEL: DEBUG + GEMINI_MODEL: gemini-1.5-flash + REDIS_URL: redis://redis:6379/0 + FLASK_ENV: development diff --git a/deploy/tilt/frontend.yaml b/deploy/tilt/frontend.yaml new file mode 100644 index 00000000..55356795 --- /dev/null +++ b/deploy/tilt/frontend.yaml @@ -0,0 +1,36 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: finmind + labels: + app: frontend +spec: + replicas: 1 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + containers: + - name: frontend + image: finmind-frontend + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend + namespace: finmind + labels: + app: frontend +spec: + selector: + app: frontend + ports: + - port: 80 + targetPort: 80 diff --git a/deploy/tilt/namespace.yaml b/deploy/tilt/namespace.yaml new file mode 100644 index 00000000..a4248495 --- /dev/null +++ b/deploy/tilt/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: finmind + labels: + app: finmind diff --git a/deploy/tilt/postgres.yaml b/deploy/tilt/postgres.yaml new file mode 100644 index 00000000..269feda0 --- /dev/null +++ b/deploy/tilt/postgres.yaml @@ -0,0 +1,45 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + namespace: finmind + labels: + app: postgres +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:16 + ports: + - containerPort: 5432 + envFrom: + - secretRef: + name: finmind-secrets + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + volumes: + - name: postgres-data + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: finmind + labels: + app: postgres +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 diff --git a/deploy/tilt/redis.yaml b/deploy/tilt/redis.yaml new file mode 100644 index 00000000..dc38a8e7 --- /dev/null +++ b/deploy/tilt/redis.yaml @@ -0,0 +1,36 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: finmind + labels: + app: redis +spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7 + ports: + - containerPort: 6379 +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: finmind + labels: + app: redis +spec: + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 diff --git a/deploy/tilt/secrets.yaml b/deploy/tilt/secrets.yaml new file mode 100644 index 00000000..8147b36e --- /dev/null +++ b/deploy/tilt/secrets.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + name: finmind-secrets + namespace: finmind + labels: + app: finmind +type: Opaque +stringData: + POSTGRES_USER: finmind + POSTGRES_PASSWORD: finmind_dev + POSTGRES_DB: finmind + JWT_SECRET: dev-jwt-secret-change-in-production + GEMINI_API_KEY: "" + OPENAI_API_KEY: "" diff --git a/deploy/vercel/vercel.json b/deploy/vercel/vercel.json new file mode 100644 index 00000000..956fc8d9 --- /dev/null +++ b/deploy/vercel/vercel.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "buildCommand": "cd app && npm install && npm run build", + "outputDirectory": "app/dist", + "installCommand": "cd app && npm install", + "rewrites": [ + { + "source": "/((?!assets/).*)", + "destination": "/index.html" + } + ], + "headers": [ + { + "source": "/assets/(.*)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + }, + { + "source": "/(.*)", + "headers": [ + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-XSS-Protection", "value": "1; mode=block" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } + ] + } + ], + "env": { + "VITE_API_URL": "https://api.finmind.example.com" + }, + "regions": ["iad1"] +} diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 00000000..88393473 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,492 @@ +# FinMind — Universal Deployment Guide + +> One-click (or one-command) deployment across 15 platforms: Docker, Kubernetes (raw + Helm + Tilt), Railway, Heroku, Render, Fly.io, DigitalOcean (App Platform + Droplet), AWS ECS Fargate, GCP Cloud Run, Azure Container Apps, Netlify, and Vercel. + +## Table of Contents + +- [Quick Start](#quick-start) +- [Architecture](#architecture) +- [Prerequisites](#prerequisites) +- [Platform Guides](#platform-guides) + - [Docker Compose](#docker-compose) + - [Kubernetes](#kubernetes) + - [Helm](#helm) + - [Tilt (Local K8s Dev)](#tilt) + - [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) + - [GCP Cloud Run](#gcp-cloud-run) + - [Azure Container Apps](#azure-container-apps) + - [Netlify (Frontend)](#netlify) + - [Vercel (Frontend)](#vercel) +- [Environment Variables](#environment-variables) +- [Health Checks](#health-checks) +- [Monitoring](#monitoring) +- [Troubleshooting](#troubleshooting) + +--- + +## Quick Start + +```bash +# Clone the repo +git clone https://github.com/rohitdash08/FinMind.git +cd FinMind + +# Copy environment file +cp .env.example .env +# Edit .env with your values + +# Deploy to any platform: +./deploy.sh docker # Local Docker Compose +./deploy.sh helm # Kubernetes via Helm +./deploy.sh tilt # Local K8s dev with Tilt +./deploy.sh railway # Railway PaaS +./deploy.sh fly # Fly.io +# ... see ./deploy.sh --list for all options +``` + +## Architecture + +``` +┌─────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Frontend │────▶│ Nginx │────▶│ Backend │ +│ React/Vite │ │ (reverse │ │ Flask/ │ +│ Port: 80 │ │ proxy) │ │ Gunicorn │ +└─────────────┘ └──────────────┘ │ Port: 8000 │ + └──────┬───────┘ + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ + │ PostgreSQL │ │ Redis │ │ AI │ + │ Port:5432 │ │ Port:6379 │ │ Services │ + └───────────┘ └───────────┘ │ (Gemini/ │ + │ OpenAI) │ + └──────────┘ +``` + +**Services:** +| Service | Technology | Port | Description | +|---------|-----------|------|-------------| +| Frontend | React + Vite + Nginx | 80 | SPA with expense tracking UI | +| Backend | Python Flask + Gunicorn | 8000 | REST API server | +| PostgreSQL | PostgreSQL 16 | 5432 | Primary database | +| Redis | Redis 7 | 6379 | Cache and session store | +| Nginx | Nginx 1.27 | 8080 | Reverse proxy (Docker Compose) | + +--- + +## Prerequisites + +- Docker 20+ and Docker Compose v2 +- For Kubernetes: `kubectl` configured, cluster access +- For Tilt: [Tilt](https://docs.tilt.dev/install.html) + local K8s (Docker Desktop/minikube/kind) +- For Helm: Helm 3.x +- Platform CLI tools as needed (see individual guides) + +--- + +## Platform Guides + +### Docker Compose + +The simplest deployment path. Includes full monitoring stack (Prometheus, Grafana, Loki). + +```bash +cp .env.example .env +# Edit .env with production values +docker compose up -d --build +``` + +**Endpoints:** +- Frontend: http://localhost:5173 +- Backend API: http://localhost:8000 +- Backend (via Nginx): http://localhost:8080 +- Grafana: http://localhost:3000 +- Prometheus: http://localhost:9090 + +**Teardown:** +```bash +docker compose down -v # -v removes volumes +``` + +--- + +### Kubernetes + +Raw Kubernetes manifests for any cluster. + +```bash +# Create namespace and secrets +kubectl apply -f deploy/k8s/namespace.yaml + +# Create secrets (edit with your values first) +cp deploy/k8s/secrets.example.yaml deploy/k8s/secrets.yaml +# Edit deploy/k8s/secrets.yaml with base64-encoded values +kubectl apply -f deploy/k8s/secrets.yaml + +# Deploy application stack +kubectl apply -f deploy/k8s/app-stack.yaml + +# Deploy monitoring stack (optional) +kubectl apply -f deploy/k8s/monitoring-stack.yaml + +# Verify +kubectl get pods -n finmind +``` + +**Port forwarding for local access:** +```bash +kubectl port-forward -n finmind svc/backend 8000:8000 +kubectl port-forward -n finmind svc/nginx 8080:80 +``` + +--- + +### Helm + +Production-grade Helm chart with TLS, HPA, and observability. + +```bash +# Install with defaults +helm upgrade --install finmind deploy/helm/finmind \ + --namespace finmind \ + --create-namespace + +# Install with custom values +helm upgrade --install finmind deploy/helm/finmind \ + --namespace finmind \ + --create-namespace \ + --set secrets.jwtSecret=my-secret \ + --set secrets.geminiApiKey=my-key \ + --set ingress.hosts[0].host=finmind.example.com \ + --set ingress.tls[0].secretName=finmind-tls + +# Or use a values file +helm upgrade --install finmind deploy/helm/finmind \ + --namespace finmind \ + --create-namespace \ + -f my-values.yaml +``` + +**Features:** +- TLS via cert-manager (auto Let's Encrypt) +- HPA: backend scales 2→8 pods on CPU/memory +- Health probes on all containers +- Prometheus scrape annotations +- Resource requests/limits +- Init containers wait for DB/Redis + +**Uninstall:** +```bash +helm uninstall finmind -n finmind +``` + +--- + +### Tilt + +Local Kubernetes development with live-reload. + +```bash +# Prerequisites: Docker Desktop with K8s enabled (or minikube/kind) +# Install Tilt: https://docs.tilt.dev/install.html + +tilt up +``` + +**Dashboard:** http://localhost:10350 + +**Port forwards (automatic):** +- Frontend: http://localhost:5173 +- Backend: http://localhost:8000 +- PostgreSQL: localhost:5432 +- Redis: localhost:6379 + +**Features:** +- Live code sync (no rebuild needed for Python/React changes) +- Automatic dependency ordering (DB → Redis → Backend → Frontend) +- Manual tasks: `db-seed`, `run-tests` (trigger from Tilt dashboard) + +**Teardown:** +```bash +tilt down +``` + +--- + +### Railway + +```bash +# Option 1: Deploy button +# Click the "Deploy on Railway" button in the README + +# Option 2: CLI +npm install -g @railway/cli +railway login +railway link +railway up +``` + +Config: `deploy/railway/railway.json` + +--- + +### Heroku + +```bash +# Option 1: Heroku Button +# Click: https://heroku.com/deploy?template=https://github.com/rohitdash08/FinMind + +# Option 2: CLI +heroku create finmind-app +heroku addons:create heroku-postgresql:essential-0 +heroku addons:create heroku-redis:mini +heroku stack:set container +heroku config:set JWT_SECRET=$(openssl rand -hex 32) +git push heroku main +``` + +Config: `deploy/heroku/heroku.yml`, `deploy/heroku/app.json` + +--- + +### Render + +```bash +# Blueprint deployment (recommended) +# 1. Go to https://render.com/deploy +# 2. Connect your GitHub repo +# 3. Render auto-detects deploy/render/render.yaml +``` + +Config: `deploy/render/render.yaml` + +--- + +### Fly.io + +```bash +# Install flyctl +curl -L https://fly.io/install.sh | sh +fly auth login + +# Deploy (creates apps, databases, and deploys) +./deploy.sh fly +# or directly: +bash deploy/fly/deploy.sh +``` + +Config: `deploy/fly/fly.backend.toml`, `deploy/fly/fly.frontend.toml` + +--- + +### DigitalOcean App Platform + +```bash +# Option 1: CLI +doctl apps create --spec deploy/digitalocean/app-spec.yaml + +# Option 2: Dashboard +# Import deploy/digitalocean/app-spec.yaml in DO dashboard +``` + +Config: `deploy/digitalocean/app-spec.yaml` + +--- + +### DigitalOcean Droplet + +```bash +# On a fresh Ubuntu 22.04 droplet: +curl -sSL https://raw.githubusercontent.com/rohitdash08/FinMind/main/deploy/digitalocean/droplet-setup.sh | bash +``` + +Includes: Docker, UFW firewall, fail2ban, systemd service, log rotation. + +Config: `deploy/digitalocean/droplet-setup.sh` + +--- + +### AWS ECS Fargate + +```bash +# Prerequisites: AWS CLI configured, VPC with subnets +./deploy.sh aws + +# Or step-by-step: +bash deploy/aws/deploy.sh +``` + +Creates: ECR repos, ECS cluster, task definition, ALB, auto-scaling (2-10 tasks), CloudWatch logging. + +Config: `deploy/aws/ecs-task-definition.json`, `deploy/aws/ecs-service.json` + +--- + +### GCP Cloud Run + +```bash +# Prerequisites: gcloud CLI configured, project selected +./deploy.sh gcp + +# Or step-by-step: +bash deploy/gcp/deploy.sh +``` + +Creates: Cloud SQL PostgreSQL, Memorystore Redis, VPC connector, Secret Manager entries, Cloud Run services. + +Config: `deploy/gcp/cloudbuild.yaml`, `deploy/gcp/deploy.sh` + +--- + +### Azure Container Apps + +```bash +# Prerequisites: Azure CLI configured +./deploy.sh azure + +# Or step-by-step: +bash deploy/azure/deploy.sh + +# Or use Bicep IaC: +az deployment group create \ + --resource-group finmind-rg \ + --template-file deploy/azure/bicep/main.bicep \ + --parameters appName=finmind +``` + +Config: `deploy/azure/deploy.sh`, `deploy/azure/bicep/main.bicep` + +--- + +### Netlify + +Frontend-only deployment. Backend must be hosted separately. + +```bash +# 1. Connect repo in Netlify dashboard +# 2. Settings: +# Base directory: app +# Build command: npm run build +# Publish directory: app/dist +# 3. Environment variables: +# VITE_API_URL = https://your-backend-url + +# Or CLI: +cd app && netlify deploy --prod +``` + +Config: `deploy/netlify/netlify.toml` + +--- + +### Vercel + +Frontend-only deployment. Backend must be hosted separately. + +```bash +# 1. Import project in Vercel dashboard +# 2. Root directory: app +# 3. Framework: Vite +# 4. Environment variables: +# VITE_API_URL = https://your-backend-url + +# Or CLI: +cd app && vercel --prod +``` + +Config: `deploy/vercel/vercel.json` + +--- + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `DATABASE_URL` | Yes | — | PostgreSQL connection string | +| `REDIS_URL` | Yes | `redis://redis:6379/0` | Redis connection string | +| `JWT_SECRET` | Yes | — | Secret for JWT token signing | +| `POSTGRES_USER` | Yes | `finmind` | PostgreSQL username | +| `POSTGRES_PASSWORD` | Yes | — | PostgreSQL password | +| `POSTGRES_DB` | Yes | `finmind` | PostgreSQL database name | +| `GEMINI_API_KEY` | No | — | Google Gemini API key (AI features) | +| `GEMINI_MODEL` | No | `gemini-1.5-flash` | Gemini model name | +| `OPENAI_API_KEY` | No | — | OpenAI API key (AI features) | +| `LOG_LEVEL` | No | `INFO` | Python log level | +| `SMTP_URL` | No | — | SMTP URL for email reminders | +| `EMAIL_FROM` | No | — | Sender email address | +| `TWILIO_ACCOUNT_SID` | No | — | Twilio SID (WhatsApp) | +| `TWILIO_AUTH_TOKEN` | No | — | Twilio auth token | +| `TWILIO_WHATSAPP_FROM` | No | — | Twilio WhatsApp number | + +--- + +## Health Checks + +| Endpoint | Port | Expected | Description | +|----------|------|----------|-------------| +| `GET /health` | 8000 | `{"status":"ok"}` 200 | Backend health | +| `GET /metrics` | 8000 | Prometheus format | Prometheus metrics | +| `GET /` | 80 | HTML 200 | Frontend reachable | + +All deployment configs use these endpoints for readiness/liveness probes. + +--- + +## Monitoring + +The Docker Compose and Kubernetes deployments include a full monitoring stack: + +- **Prometheus** — Metrics collection (port 9090) +- **Grafana** — Dashboards and alerting (port 3000) +- **Loki** — Log aggregation (port 3100) +- **Promtail** — Log shipping to Loki +- **Exporters** — PostgreSQL, Redis, Nginx metrics + +Default Grafana login: `finmind_admin` / (see `GRAFANA_ADMIN_PASSWORD` in `.env`) + +--- + +## Troubleshooting + +### Backend can't connect to PostgreSQL +```bash +# Docker Compose: check if postgres is healthy +docker compose ps +docker compose logs postgres + +# Kubernetes: check pod status +kubectl get pods -n finmind +kubectl logs -n finmind deploy/postgres +``` + +### Frontend shows blank page +- Ensure `VITE_API_URL` points to the correct backend URL +- Check browser console for CORS errors +- For PaaS: ensure frontend and backend are on the same domain or CORS is configured + +### Database migrations +```bash +# Docker: run init-db +docker compose exec backend python -m flask --app wsgi:app init-db + +# Kubernetes: +kubectl exec -n finmind deploy/backend -- python -m flask --app wsgi:app init-db +``` + +### View logs +```bash +# Docker +docker compose logs -f backend + +# Kubernetes +kubectl logs -n finmind deploy/backend -f + +# Tilt: check http://localhost:10350 +```