From e6c2042df602e6bc8d916b5e710645eab54a68e9 Mon Sep 17 00:00:00 2001 From: Hristo <53634432+izo0x90@users.noreply.github.com> Date: Fri, 10 May 2024 16:07:58 -0400 Subject: [PATCH] Backend GKE Deploy, access key for backend - Configs and automation for deploying backend to GKE - First steps to adding an optional token check for requests to backend - First steps frontend sending optional token to backend when configured --- Makefile | 18 ++++++ README.md | 20 +++++++ app-docker-compose.yaml | 13 ++++ app.dockerfile | 5 +- deploy/gcp/Makefile | 35 ++++++----- deploy/gcp/main.tf | 100 +++++++++++++++++++++++++++++-- docker-compose.yaml | 12 +++- sample.env | 20 +++++++ src/app.ts | 7 ++- src/auth.ts | 18 ++++++ src/config.ts | 29 +++++++-- ui/components/ChatWindow.tsx | 5 +- ui/components/SearchImages.tsx | 5 +- ui/components/SearchVideos.tsx | 5 +- ui/components/SettingsDialog.tsx | 5 +- ui/lib/config.ts | 20 +++++++ ui/lib/utils.ts | 18 ++++++ 17 files changed, 296 insertions(+), 39 deletions(-) create mode 100644 Makefile create mode 100644 app-docker-compose.yaml create mode 100644 sample.env create mode 100644 src/auth.ts create mode 100644 ui/lib/config.ts diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cef1169 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: run +run: + docker compose -f docker-compose.yaml up + + +.PHONY: rebuild-run +rebuild-run: + docker compose -f docker-compose.yaml up --build + + +.PHONY: run-app-only +run-app-only: + docker compose -f app-docker-compose.yaml up + + +.PHONY: rebuild-run-app-only +rebuild-run-app-only: + docker compose -f app-docker-compose.yaml up --build diff --git a/README.md b/README.md index e45e80a..79989e3 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,26 @@ You need to edit the ports accordingly. [![Deploy to RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploylobe.svg)](https://repocloud.io/details/?app_id=267) +## Deploy Perplexica backend to Google GKE + +0: Install `docker` and `terraform` (Process specific to your system) +1a: Copy the `sample.env` file to `.env` +1b: Copy the `deploy/gcp/sample.env` file to `deploy/gcp/.env` +2a: Fillout desired LLM provider access keys etc. in `.env` + - Note: you will have to comeback and edit this file again once you have the address of the K8s backend deploy +2b: Fillout the GCP info in `deploy/gcp/.env` +3: Edit `GCP_REPO` to the correct docker image repo path if you are using something other than Container registry +4: Edit the `PREFIX` if you would like images and GKE entities to be prefixed with something else +5: In `deploy/gcp` run `make init` to initialize terraform +6: Follow the normal Preplexica configuration steps outlined in the project readme +7: Auth docker with the appropriate credential for repo Ex. for `gcr.io` -> `gcloud auth configure-docker` +8: In `deploy/gcp` run `make build-deplpy` to build and push the project images to the repo, create a GKE cluster and deploy the app +9: Once deployed successfully edit the `.env` file in the root project folder and update the `REMOTE_BACKEND_ADDRESS` with the remote k8s deployment address and port +10: In root project folder run `make rebuild-run-app-only` + +If you configured everything correctly frontend app will run locally and provide you with a local url to open it. +Now you can run queries against the remotely deployed backend from your local machine. :celebrate: + ## Upcoming Features - [ ] Finalizing Copilot Mode diff --git a/app-docker-compose.yaml b/app-docker-compose.yaml new file mode 100644 index 0000000..4bfef32 --- /dev/null +++ b/app-docker-compose.yaml @@ -0,0 +1,13 @@ +services: + perplexica-frontend: + build: + context: . + dockerfile: app.dockerfile + args: + - SUPER_SECRET_KEY=${SUPER_SECRET_KEY} + - NEXT_PUBLIC_API_URL=http://${REMOTE_BACKEND_ADDRESS}/api + - NEXT_PUBLIC_WS_URL=ws://${REMOTE_BACKEND_ADDRESS} + expose: + - 3000 + ports: + - 3000:3000 diff --git a/app.dockerfile b/app.dockerfile index 105cf86..3e67ee4 100644 --- a/app.dockerfile +++ b/app.dockerfile @@ -2,8 +2,11 @@ FROM node:alpine ARG NEXT_PUBLIC_WS_URL ARG NEXT_PUBLIC_API_URL +ARG SUPER_SECRET_KEY + ENV NEXT_PUBLIC_WS_URL=${NEXT_PUBLIC_WS_URL} ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +ENV SUPER_SECRET_KEY=${SUPER_SECRET_KEY} WORKDIR /home/perplexica @@ -12,4 +15,4 @@ COPY ui /home/perplexica/ RUN yarn install RUN yarn build -CMD ["yarn", "start"] \ No newline at end of file +CMD ["yarn", "start"] diff --git a/deploy/gcp/Makefile b/deploy/gcp/Makefile index b8a0e59..9d959f2 100644 --- a/deploy/gcp/Makefile +++ b/deploy/gcp/Makefile @@ -1,21 +1,14 @@ -# USAGE: -# 0: Install `docker` and `terraform` (Process specific to your system) -# 1: Copy the sample.env file to .env -# 2: Fillout the GCP info in .env -# 3: Edit GCP_REPO to the correct docker image repo path if you are using something other than Container registry -# 4: Edit the PREFIX if you would like images and GKE entities to be prefixed with something else -# 5: Run `make init` to initialize terraform -# 6: Follow the normal Preplexica configuration steps outlined in the project readme -# 7: Run `make build-deplpy` to build and push the project images to the repo, create a GKE cluster and deploy the app -# -# NOTES/ WARNINGS: -# - The app endpoint is unsecured and exposed to the internet at large, you will need to implement your desired auth -# - No auto scaling is enabled for this cluster and deployments, edit the terraform files accordingly for that +# Adds all the deployment relevant sensitive information about project include .env +# Adds secrets/ keys we have define for the project locally and deployment +include ../../.env + # Use `location-id-docker.pkg` for artifact registry Ex. west-1-docker.pkg GCP_REPO=gcr.io PREFIX=perplexica +SEARCH_PORT=8080 +BACKEND_PORT=3001 SEARCH_IMAGE_TAG=$(GCP_REPO)/$(GCP_PROJECT_ID)/$(PREFIX)-searxng:latest BACKEND_IMAGE_TAG=$(GCP_REPO)/$(GCP_PROJECT_ID)/$(PREFIX)-backend:latest APP_IMAGE_TAG=$(GCP_REPO)/$(GCP_PROJECT_ID)/$(PREFIX)-app:latest @@ -38,8 +31,10 @@ show_config: && echo $(GCP_SERVICE_ACCOUNT_KEY_FILE) \ && echo $(SEARCH_IMAGE_TAG) \ && echo $(BACKEND_IMAGE_TAG) \ - && echo $(APP_IMAGE_TAG) - + && echo $(APP_IMAGE_TAG) \ + && echo $(SEARCH_PORT) \ + && echo $(BACKEND_PORT) \ + && echo $(OPENAI) .PHONY: docker-build-push-searxng docker-build-push-searxng: @@ -49,14 +44,15 @@ docker-build-push-searxng: .PHONY: docker-build-push-backend docker-build-push-backend: - cd ../../ && docker build -f ./backed.dockerfile -t $(BACKEND_IMAGE_TAG) . --platform="linux/amd64" + cd ../../ && docker build -f ./backend.dockerfile -t $(BACKEND_IMAGE_TAG) . --platform="linux/amd64" docker push $(BACKEND_IMAGE_TAG) .PHONY: docker-build-push-app docker-build-push-app: - cd ../../ && docker build -f ./app.dockerfile -t $(APP_IMAGE_TAG) . --platform="linux/amd64" - docker push $(APP_IMAGE_TAG) + # + # cd ../../ && docker build -f ./app.dockerfile -t $(APP_IMAGE_TAG) . --platform="linux/amd64" + # docker push $(APP_IMAGE_TAG) .PHONY: init @@ -73,6 +69,9 @@ deploy: && export TF_VAR_search_image=$(SEARCH_IMAGE_TAG) \ && export TF_VAR_backend_image=$(BACKEND_IMAGE_TAG) \ && export TF_VAR_app_image=$(APP_IMAGE_TAG) \ + && export TF_VAR_search_port=$(SEARCH_PORT) \ + && export TF_VAR_backend_port=$(BACKEND_PORT) \ + && export TF_VAR_open_ai=$(OPENAI) \ && terraform apply diff --git a/deploy/gcp/main.tf b/deploy/gcp/main.tf index e821a6e..9d53397 100644 --- a/deploy/gcp/main.tf +++ b/deploy/gcp/main.tf @@ -35,6 +35,9 @@ provider "kubernetes" { ) } +##################################################################################################### +# SearXNG - Search engine deployment and service +##################################################################################################### resource "kubernetes_deployment" "searxng" { metadata { name = "searxng" @@ -60,7 +63,7 @@ resource "kubernetes_deployment" "searxng" { image = var.search_image name = "searxng-container" port { - container_port = 8080 + container_port = var.search_port } } } @@ -80,14 +83,88 @@ resource "kubernetes_service" "searxng_service" { } port { - port = 8080 - target_port = 8080 + port = var.search_port + target_port = var.search_port } type = "LoadBalancer" } } +##################################################################################################### +# Perplexica - backend deployment and service +##################################################################################################### +resource "kubernetes_deployment" "backend" { + metadata { + name = "backend" + labels = { + app = "backend" + } + } + spec { + replicas = 1 + selector { + match_labels = { + component = "backend" + } + } + template { + metadata { + labels = { + component = "backend" + } + } + spec { + container { + image = var.backend_image + name = "backend-container" + port { + container_port = var.backend_port + } + env { + # searxng service ip + name = "SEARXNG_API_URL" + value = "http://${kubernetes_service.searxng_service.status[0].load_balancer[0].ingress[0].ip}:${var.search_port}" + } + env { + # openai key + name = "OPENAI" + value = var.open_ai + } + env { + # port + name = "PORT" + value = var.backend_port + } + } + } + } + } +} + +resource "kubernetes_service" "backend_service" { + metadata { + name = "backend-service" + namespace = "default" + } + + spec { + selector = { + component = "backend" + } + + port { + port = var.backend_port + target_port = var.backend_port + } + + type = "LoadBalancer" + } +} + +##################################################################################################### +# Variable and module definitions +##################################################################################################### variable "project_id" { description = "The ID of the project in which the resources will be deployed." type = string @@ -113,7 +190,7 @@ variable "search_image" { type = string } -variable "backed_image" { +variable "backend_image" { description = "Tag for the Perplexica backend image" type = string } @@ -123,6 +200,21 @@ variable "app_image" { type = string } +variable "open_ai" { + description = "OPENAI access key" + type = string +} + +variable "search_port" { + description = "Port for searxng service" + type = number +} + +variable "backend_port" { + description = "Port for backend service" + type = number +} + module "gke-cluster" { source = "./gke-cluster" diff --git a/docker-compose.yaml b/docker-compose.yaml index 2ca3ca5..3c0bb78 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -15,7 +15,12 @@ services: context: . dockerfile: backend.dockerfile args: - - SEARXNG_API_URL=http://searxng:8080 + - SEARXNG_API_URL=null + environment: + SEARXNG_API_URL: "http://searxng:8080" + OPENAI: ${OPENAI} + GROQ: ${GROQ} + OLLAMA_API_URL: ${OLLAMA_API_URL} depends_on: - searxng expose: @@ -30,8 +35,9 @@ services: context: . dockerfile: app.dockerfile args: - - NEXT_PUBLIC_API_URL=http://127.0.0.1:3001/api - - NEXT_PUBLIC_WS_URL=ws://127.0.0.1:3001 + - SUPER_SECRET_KEY=${SUPER_SECRET_KEY} + - NEXT_PUBLIC_API_URL=http://${REMOTE_BACKEND_ADDRESS}/api + - NEXT_PUBLIC_WS_URL=ws://${REMOTE_BACKEND_ADDRESS} depends_on: - perplexica-backend expose: diff --git a/sample.env b/sample.env new file mode 100644 index 0000000..2c1861d --- /dev/null +++ b/sample.env @@ -0,0 +1,20 @@ +# Copy this file over to .env and fill in the desired config. +# .env will become available to docker compose and these values will be +# used when running docker compose up + +# Edit to set OpenAI access key +OPENAI=ADD OPENAI KEY HERE + +# Uncomment and edit to set GROQ access key +# GROQ: ${GROQ} + +# Uncomment and edit to set OLLAMA Url +# OLLAMA_API_URL: ${OLLAMA_API_URL} + +# Address and port of the remotely deployed Perplexica backend +REMOTE_BACKEND_ADDRESS=111.111.111.111:0000 + +# Uncomment and edit to configure backend to reject requests without token +# leave commented to have open access to all endpoints +# Secret key to "secure" backend +# SUPER_SECRET_KEY=THISISASUPERSECRETKEYSERIOUSLY diff --git a/src/app.ts b/src/app.ts index b8c2371..1406809 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,7 +3,8 @@ import express from 'express'; import cors from 'cors'; import http from 'http'; import routes from './routes'; -import { getPort } from './config'; +import { requireAccessKey } from './auth'; +import { getAccessKey, getPort } from './config'; import logger from './utils/logger'; const port = getPort(); @@ -23,6 +24,10 @@ app.get('/api', (_, res) => { res.status(200).json({ status: 'ok' }); }); +if (getAccessKey()) { + app.all('*', requireAccessKey); +}; + server.listen(port, () => { logger.info(`Server is running on port ${port}`); }); diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..4255cfe --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,18 @@ +import { + getAccessKey, +} from '../config'; + +const requireAccessKey = (req, res, next) => { + const authHeader = req.headers.authorization; + + if (authHeader) { + const token = authHeader.split(' ')[1]; + + if (token !== getAccessKey()) { + return res.sendStatus(403); + } + next(); + } else { + res.sendStatus(401); + } +}; diff --git a/src/config.ts b/src/config.ts index 7c0c7f1..05d824d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,7 @@ interface Config { GENERAL: { PORT: number; SIMILARITY_MEASURE: string; + SUPER_SECRET_KEY: string; }; API_KEYS: { OPENAI: string; @@ -28,18 +29,38 @@ const loadConfig = () => fs.readFileSync(path.join(__dirname, `../${configFileName}`), 'utf-8'), ) as any as Config; +const loadEnv = () => { + return { + GENERAL: { + PORT: Number(process.env.PORT), + SIMILARITY_MEASURE: process.env.SIMILARITY_MEASURE, + SUPER_SECRET_KEY: process.env.SUPER_SECRET_KEY + }, + API_KEYS: { + OPENAI: process.env.OPENAI, + GROQ: process.env.GROQ + }, + API_ENDPOINTS: { + SEARXNG: process.env.SEARXNG_API_URL, + OLLAMA: process.env.OLLAMA_API_URL + } + } as Config; +}; + export const getPort = () => loadConfig().GENERAL.PORT; +export const getAccessKey = () => loadEnv().GENERAL.SUPER_SECRET_KEY || loadConfig().GENERAL.SUPER_SECRET_KEY; + export const getSimilarityMeasure = () => loadConfig().GENERAL.SIMILARITY_MEASURE; -export const getOpenaiApiKey = () => loadConfig().API_KEYS.OPENAI; +export const getOpenaiApiKey = () => loadEnv().API_KEYS.OPENAI || loadConfig().API_KEYS.OPENAI; -export const getGroqApiKey = () => loadConfig().API_KEYS.GROQ; +export const getGroqApiKey = () => loadEnv().API_KEYS.GROQ || loadConfig().API_KEYS.GROQ; -export const getSearxngApiEndpoint = () => loadConfig().API_ENDPOINTS.SEARXNG; +export const getSearxngApiEndpoint = () => loadEnv().API_ENDPOINTS.SEARXNG || loadConfig().API_ENDPOINTS.SEARXNG; -export const getOllamaApiEndpoint = () => loadConfig().API_ENDPOINTS.OLLAMA; +export const getOllamaApiEndpoint = () => loadEnv().API_ENDPOINTS.OLLAMA || loadConfig().API_ENDPOINTS.OLLAMA; export const updateConfig = (config: RecursivePartial) => { const currentConfig = loadConfig(); diff --git a/ui/components/ChatWindow.tsx b/ui/components/ChatWindow.tsx index 6f58757..87a8ad3 100644 --- a/ui/components/ChatWindow.tsx +++ b/ui/components/ChatWindow.tsx @@ -6,6 +6,7 @@ import Navbar from './Navbar'; import Chat from './Chat'; import EmptyChat from './EmptyChat'; import { toast } from 'sonner'; +import { clientFetch } from '@/lib/utils'; export type Message = { id: string; @@ -34,8 +35,8 @@ const useSocket = (url: string) => { !embeddingModel || !embeddingModelProvider ) { - const providers = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/models`, + const providers = await clientFetch( + '/models', { headers: { 'Content-Type': 'application/json', diff --git a/ui/components/SearchImages.tsx b/ui/components/SearchImages.tsx index aa70c96..076537f 100644 --- a/ui/components/SearchImages.tsx +++ b/ui/components/SearchImages.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import Lightbox from 'yet-another-react-lightbox'; import 'yet-another-react-lightbox/styles.css'; import { Message } from './ChatWindow'; +import { clientFetch } from '@/lib/utils'; type Image = { url: string; @@ -33,8 +34,8 @@ const SearchImages = ({ const chatModelProvider = localStorage.getItem('chatModelProvider'); const chatModel = localStorage.getItem('chatModel'); - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/images`, + const res = await clientFetch( + '/images', { method: 'POST', headers: { diff --git a/ui/components/SearchVideos.tsx b/ui/components/SearchVideos.tsx index b5ff6c5..b91627c 100644 --- a/ui/components/SearchVideos.tsx +++ b/ui/components/SearchVideos.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import Lightbox, { GenericSlide, VideoSlide } from 'yet-another-react-lightbox'; import 'yet-another-react-lightbox/styles.css'; import { Message } from './ChatWindow'; +import { clientFetch } from '@/lib/utils'; type Video = { url: string; @@ -46,8 +47,8 @@ const Searchvideos = ({ const chatModelProvider = localStorage.getItem('chatModelProvider'); const chatModel = localStorage.getItem('chatModel'); - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/videos`, + const res = await clientFetch( + '/videos', { method: 'POST', headers: { diff --git a/ui/components/SettingsDialog.tsx b/ui/components/SettingsDialog.tsx index be94db2..950338e 100644 --- a/ui/components/SettingsDialog.tsx +++ b/ui/components/SettingsDialog.tsx @@ -1,6 +1,7 @@ import { Dialog, Transition } from '@headlessui/react'; import { CloudUpload, RefreshCcw, RefreshCw } from 'lucide-react'; import React, { Fragment, useEffect, useState } from 'react'; +import { clientFetch } from '@/lib/utils'; interface SettingsType { chatModelProviders: { @@ -42,7 +43,7 @@ const SettingsDialog = ({ if (isOpen) { const fetchConfig = async () => { setIsLoading(true); - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/config`, { + const res = await clientFetch('/config', { headers: { 'Content-Type': 'application/json', }, @@ -102,7 +103,7 @@ const SettingsDialog = ({ setIsUpdating(true); try { - await fetch(`${process.env.NEXT_PUBLIC_API_URL}/config`, { + await clientFetch('/config', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/ui/lib/config.ts b/ui/lib/config.ts new file mode 100644 index 0000000..de349c4 --- /dev/null +++ b/ui/lib/config.ts @@ -0,0 +1,20 @@ +interface Config { + GENERAL: { + SUPER_SECRET_KEY: string; + NEXT_PUBLIC_API_URL: string; + }; +} + +const loadEnv = () => { + return { + GENERAL: { + SUPER_SECRET_KEY: process.env.SUPER_SECRET_KEY!, + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL!, + NEXT_PUBLIC_WS_URL: process.env.NEXT_PUBLIC_WS_URL! + }, + } as Config; +}; + +export const getAccessKey = () => loadEnv().GENERAL.SUPER_SECRET_KEY; + +export const getBackendURL = () => loadEnv().GENERAL.NEXT_PUBLIC_API_URL; diff --git a/ui/lib/utils.ts b/ui/lib/utils.ts index 6b35b90..3b25633 100644 --- a/ui/lib/utils.ts +++ b/ui/lib/utils.ts @@ -1,5 +1,6 @@ import clsx, { ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; +import { getAccessKey, getBackendURL } from './config' export const cn = (...classes: ClassValue[]) => twMerge(clsx(...classes)); @@ -19,3 +20,20 @@ export const formatTimeDifference = (date1: Date, date2: Date): string => { else return `${Math.floor(diffInSeconds / 31536000)} year${Math.floor(diffInSeconds / 31536000) !== 1 ? 's' : ''}`; }; + +export const clientFetch = async (path: string, payload: any): Promise => { + let headers = payload.headers; + const url = `${getBackendURL()}${path}`; + const secret_token = getAccessKey(); + + if (secret_token) { + if (headers == null) { + headers = {}; + }; + + headers['Authorization'] = `Bearer ${secret_token}`; + payload.headers = headers; + }; + + return await fetch(url, payload); +};