From 21ddee94f7a73d083dc7d64c7d4bdea250e930d5 Mon Sep 17 00:00:00 2001 From: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:37:25 +0500 Subject: [PATCH] Add Knowledge Explorer deployment templates --- .dockerignore | 103 ++++++++++++++++ Dockerfile | 44 ++++--- deploy/azure/README.md | 13 ++ deploy/azure/azure.yaml | 14 +++ deploy/azure/main.bicep | 113 ++++++++++++++++++ deploy/azure/main.parameters.json | 24 ++++ deploy/fly/README.md | 12 ++ deploy/fly/fly.toml | 30 +++++ deploy/gcp/README.md | 16 +++ deploy/gcp/cloudbuild.yaml | 52 ++++++++ deploy/gcp/cloudrun-service.yaml | 45 +++++++ deploy/helm/README.md | 9 ++ deploy/helm/knowledge-explorer/Chart.yaml | 6 + deploy/helm/knowledge-explorer/README.md | 9 ++ .../knowledge-explorer/templates/_helpers.tpl | 29 +++++ .../templates/configmap.yaml | 10 ++ .../templates/deployment.yaml | 89 ++++++++++++++ .../knowledge-explorer/templates/hpa.yaml | 22 ++++ .../knowledge-explorer/templates/ingress.yaml | 41 +++++++ .../knowledge-explorer/templates/service.yaml | 15 +++ .../helm/knowledge-explorer/values.prod.yaml | 27 +++++ deploy/helm/knowledge-explorer/values.yaml | 83 +++++++++++++ deploy/kubernetes/README.md | 12 ++ deploy/kubernetes/configmap.yaml | 9 ++ deploy/kubernetes/deployment.yaml | 69 +++++++++++ deploy/kubernetes/ingress.yaml | 24 ++++ deploy/kubernetes/kustomization.yaml | 8 ++ deploy/kubernetes/namespace.yaml | 4 + deploy/kubernetes/secret.yaml.example | 8 ++ deploy/kubernetes/service.yaml | 15 +++ deploy/railway/README.md | 15 +++ deploy/railway/railway.toml | 9 ++ deploy/render/README.md | 11 ++ deploy/render/render.yaml | 29 +++++ docker-compose.dev.yml | 42 +++++++ docker-compose.yml | 26 +++- explorer/vite.config.ts | 7 +- semantica/explorer/app.py | 49 ++++++-- tests/explorer/test_explorer_api.py | 38 +++++- 39 files changed, 1150 insertions(+), 31 deletions(-) create mode 100644 .dockerignore create mode 100644 deploy/azure/README.md create mode 100644 deploy/azure/azure.yaml create mode 100644 deploy/azure/main.bicep create mode 100644 deploy/azure/main.parameters.json create mode 100644 deploy/fly/README.md create mode 100644 deploy/fly/fly.toml create mode 100644 deploy/gcp/README.md create mode 100644 deploy/gcp/cloudbuild.yaml create mode 100644 deploy/gcp/cloudrun-service.yaml create mode 100644 deploy/helm/README.md create mode 100644 deploy/helm/knowledge-explorer/Chart.yaml create mode 100644 deploy/helm/knowledge-explorer/README.md create mode 100644 deploy/helm/knowledge-explorer/templates/_helpers.tpl create mode 100644 deploy/helm/knowledge-explorer/templates/configmap.yaml create mode 100644 deploy/helm/knowledge-explorer/templates/deployment.yaml create mode 100644 deploy/helm/knowledge-explorer/templates/hpa.yaml create mode 100644 deploy/helm/knowledge-explorer/templates/ingress.yaml create mode 100644 deploy/helm/knowledge-explorer/templates/service.yaml create mode 100644 deploy/helm/knowledge-explorer/values.prod.yaml create mode 100644 deploy/helm/knowledge-explorer/values.yaml create mode 100644 deploy/kubernetes/README.md create mode 100644 deploy/kubernetes/configmap.yaml create mode 100644 deploy/kubernetes/deployment.yaml create mode 100644 deploy/kubernetes/ingress.yaml create mode 100644 deploy/kubernetes/kustomization.yaml create mode 100644 deploy/kubernetes/namespace.yaml create mode 100644 deploy/kubernetes/secret.yaml.example create mode 100644 deploy/kubernetes/service.yaml create mode 100644 deploy/railway/README.md create mode 100644 deploy/railway/railway.toml create mode 100644 deploy/render/README.md create mode 100644 deploy/render/render.yaml create mode 100644 docker-compose.dev.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..d8283dfb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,103 @@ +# Start with a tiny Docker context and opt in only files used by Dockerfile. +* +!Dockerfile +!.dockerignore +!pyproject.toml +!README.md +!LICENSE +!MANIFEST.in +!semantica/ +!semantica/** +!integrations/ +!integrations/** +!explorer/ +!explorer/** + +# VCS, local config, and secrets. +.git +.git/** +.github +.github/** +.claude +.claude/** +.codex +.codex/** +.agents +.agents/** +.env +.env.* +*.env + +# Python build/test/cache artifacts. +__pycache__ +**/__pycache__ +*.py[cod] +.pytest_cache +.pytest_cache/** +.mypy_cache +.mypy_cache/** +.ruff_cache +.ruff_cache/** +.tox +.tox/** +.venv +.venv/** +venv +venv/** +coverage +coverage/** +htmlcov +htmlcov/** +*.egg-info +*.egg-info/** +build +build/** +dist +dist/** + +# Frontend dependency/build artifacts. +node_modules +node_modules/** +explorer/node_modules +explorer/node_modules/** +explorer/dist +explorer/dist/** +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Local outputs and large generated samples. +logs +logs/** +*.log +*.tmp +*.bak +*.backup +tests +tests/** +explorer/tests +explorer/tests/** +docs +docs/** +site +site/** +.mkdocs_cache +.mkdocs_cache/** +cookbook +cookbook/** +examples +examples/** +demo_assets +demo_assets/** +demo_out +demo_out/** +demo_out_* +demo_out_*/** +outputs +outputs/** +pytest-cache-files-* +pytest-cache-files-*/** +test_data +test_data/** +sample_data +sample_data/** diff --git a/Dockerfile b/Dockerfile index 55abb455..dbcd8421 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,41 @@ -FROM node:26-alpine AS frontend-builder +# syntax=docker/dockerfile:1 +FROM node:22-alpine AS frontend-builder -WORKDIR /app/semantica-explorer +WORKDIR /app +COPY explorer/package*.json ./explorer/ +WORKDIR /app/explorer +RUN npm ci +COPY explorer/ ./ +RUN mkdir -p /app/semantica && npm run build -COPY semantica-explorer/package.json semantica-explorer/package-lock.json* ./ +FROM python:3.12-slim AS runtime - -RUN npm install - - -COPY semantica-explorer/ ./ -RUN npm run build - - -FROM python:3.14-slim AS runtime +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + FALKORDB_HOST=falkordb \ + FALKORDB_PORT=6379 \ + ALLOWED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 WORKDIR /app -COPY pyproject.toml ./ -COPY semantica/ ./semantica/ +RUN groupadd --system semantica \ + && useradd --system --gid semantica --home-dir /app --shell /usr/sbin/nologin semantica +COPY pyproject.toml README.md LICENSE MANIFEST.in ./ +COPY semantica/ ./semantica/ +COPY integrations/ ./integrations/ COPY --from=frontend-builder /app/semantica/static ./semantica/static -RUN pip install --no-cache-dir ".[explorer]" +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir ".[explorer]" \ + && chown -R semantica:semantica /app + +USER semantica EXPOSE 8000 -CMD ["python", "-m", "uvicorn", "semantica.explorer.app:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import json, urllib.request; data=json.load(urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3)); raise SystemExit(0 if data.get('status') == 'ok' else 1)" + +CMD ["python", "-m", "uvicorn", "semantica.explorer.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/deploy/azure/README.md b/deploy/azure/README.md new file mode 100644 index 00000000..d9c3c6da --- /dev/null +++ b/deploy/azure/README.md @@ -0,0 +1,13 @@ +# Azure Container Apps + +Deploy with Azure Developer CLI from this template directory: + +```bash +cd deploy/azure +azd auth login +azd init --environment semantica-ke +azd env set AZURE_LOCATION eastus +azd up +``` + +The Bicep template provisions a Container Apps managed environment, HTTP ingress, scale-to-zero, max 10 replicas, and a `/api/health` liveness probe. diff --git a/deploy/azure/azure.yaml b/deploy/azure/azure.yaml new file mode 100644 index 00000000..c880004d --- /dev/null +++ b/deploy/azure/azure.yaml @@ -0,0 +1,14 @@ +name: semantica-knowledge-explorer +metadata: + template: semantica-knowledge-explorer@0.1.0 +services: + explorer: + project: ../.. + host: containerapp + language: docker + docker: + path: ./Dockerfile + context: . +infra: + provider: bicep + path: . diff --git a/deploy/azure/main.bicep b/deploy/azure/main.bicep new file mode 100644 index 00000000..2e550cd8 --- /dev/null +++ b/deploy/azure/main.bicep @@ -0,0 +1,113 @@ +targetScope = 'resourceGroup' + +param environmentName string = 'semantica-ke' +param location string = resourceGroup().location +param imageName string +param containerPort int = 8000 +param allowedOrigins string = '*' +param falkordbHost string = 'falkordb' +param falkordbPort string = '6379' + +var appName = '${environmentName}-explorer' +var logAnalyticsName = '${environmentName}-logs' +var managedEnvironmentName = '${environmentName}-env' + +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: logAnalyticsName + location: location + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + } +} + +resource managedEnvironment 'Microsoft.App/managedEnvironments@2024-03-01' = { + name: managedEnvironmentName + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalytics.properties.customerId + sharedKey: logAnalytics.listKeys().primarySharedKey + } + } + } +} + +resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { + name: appName + location: location + tags: { + 'azd-service-name': 'explorer' + } + properties: { + managedEnvironmentId: managedEnvironment.id + configuration: { + activeRevisionsMode: 'Single' + ingress: { + external: true + targetPort: containerPort + transport: 'auto' + allowInsecure: false + } + } + template: { + containers: [ + { + name: 'explorer' + image: imageName + env: [ + { + name: 'ALLOWED_ORIGINS' + value: allowedOrigins + } + { + name: 'FALKORDB_HOST' + value: falkordbHost + } + { + name: 'FALKORDB_PORT' + value: falkordbPort + } + ] + probes: [ + { + type: 'Liveness' + httpGet: { + path: '/api/health' + port: containerPort + } + initialDelaySeconds: 20 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + } + ] + resources: { + cpu: json('0.5') + memory: '1Gi' + } + } + ] + scale: { + minReplicas: 0 + maxReplicas: 10 + rules: [ + { + name: 'http-concurrency' + http: { + metadata: { + concurrentRequests: '100' + } + } + } + ] + } + } + } +} + +output endpoint string = 'https://${containerApp.properties.configuration.ingress.fqdn}' diff --git a/deploy/azure/main.parameters.json b/deploy/azure/main.parameters.json new file mode 100644 index 00000000..4526ae60 --- /dev/null +++ b/deploy/azure/main.parameters.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "imageName": { + "value": "${SERVICE_EXPLORER_IMAGE_NAME}" + }, + "allowedOrigins": { + "value": "*" + }, + "falkordbHost": { + "value": "falkordb" + }, + "falkordbPort": { + "value": "6379" + } + } +} diff --git a/deploy/fly/README.md b/deploy/fly/README.md new file mode 100644 index 00000000..c6558c8a --- /dev/null +++ b/deploy/fly/README.md @@ -0,0 +1,12 @@ +# Fly.io + +Deploy from a clean checkout using the root Dockerfile: + +```bash +flyctl auth login +flyctl launch --copy-config --config deploy/fly/fly.toml --no-deploy +flyctl secrets set FALKORDB_HOST=localhost FALKORDB_PORT=6379 +flyctl deploy --config deploy/fly/fly.toml +``` + +Change `app` in `fly.toml` before launch if the default app name is already taken. diff --git a/deploy/fly/fly.toml b/deploy/fly/fly.toml new file mode 100644 index 00000000..1e97c65a --- /dev/null +++ b/deploy/fly/fly.toml @@ -0,0 +1,30 @@ +app = "semantica-knowledge-explorer" +primary_region = "iad" +kill_signal = "SIGTERM" +kill_timeout = "30s" + +[build] +dockerfile = "Dockerfile" + +[env] +ALLOWED_ORIGINS = "https://semantica-knowledge-explorer.fly.dev" +FALKORDB_HOST = "localhost" +FALKORDB_PORT = "6379" + +[http_service] +internal_port = 8000 +force_https = true +auto_stop_machines = "stop" +auto_start_machines = true +min_machines_running = 0 + +[[http_service.checks]] +grace_period = "20s" +interval = "30s" +method = "GET" +timeout = "5s" +path = "/api/health" + +[[vm]] +size = "shared-cpu-1x" +memory = "512mb" diff --git a/deploy/gcp/README.md b/deploy/gcp/README.md new file mode 100644 index 00000000..59e413c1 --- /dev/null +++ b/deploy/gcp/README.md @@ -0,0 +1,16 @@ +# GCP Cloud Run + +Create the Secret Manager entries, then submit the Cloud Build pipeline: + +```bash +gcloud services enable cloudbuild.googleapis.com run.googleapis.com secretmanager.googleapis.com +printf "falkordb-host.example.internal" | gcloud secrets create falkordb-host --data-file=- +printf "6379" | gcloud secrets create falkordb-port --data-file=- +gcloud builds submit --config deploy/gcp/cloudbuild.yaml --substitutions _REGION=us-central1,_SERVICE_NAME=knowledge-explorer +``` + +For declarative deploys, replace `PROJECT_ID` in `cloudrun-service.yaml`, then run: + +```bash +gcloud run services replace deploy/gcp/cloudrun-service.yaml --region us-central1 +``` diff --git a/deploy/gcp/cloudbuild.yaml b/deploy/gcp/cloudbuild.yaml new file mode 100644 index 00000000..e6efe572 --- /dev/null +++ b/deploy/gcp/cloudbuild.yaml @@ -0,0 +1,52 @@ +substitutions: + _REGION: us-central1 + _SERVICE_NAME: knowledge-explorer + _IMAGE: gcr.io/$PROJECT_ID/knowledge-explorer + +steps: + - name: gcr.io/cloud-builders/docker + args: + - build + - -t + - ${_IMAGE}:$SHORT_SHA + - -t + - ${_IMAGE}:latest + - . + + - name: gcr.io/cloud-builders/docker + args: + - push + - ${_IMAGE}:$SHORT_SHA + + - name: gcr.io/cloud-builders/docker + args: + - push + - ${_IMAGE}:latest + + - name: gcr.io/google.com/cloudsdktool/cloud-sdk + entrypoint: gcloud + args: + - run + - deploy + - ${_SERVICE_NAME} + - --image + - ${_IMAGE}:$SHORT_SHA + - --region + - ${_REGION} + - --platform + - managed + - --allow-unauthenticated + - --port + - "8000" + - --min-instances + - "0" + - --max-instances + - "10" + - --set-env-vars + - ALLOWED_ORIGINS=* + - --set-secrets + - FALKORDB_HOST=falkordb-host:latest,FALKORDB_PORT=falkordb-port:latest + +images: + - ${_IMAGE}:$SHORT_SHA + - ${_IMAGE}:latest diff --git a/deploy/gcp/cloudrun-service.yaml b/deploy/gcp/cloudrun-service.yaml new file mode 100644 index 00000000..ce1c871d --- /dev/null +++ b/deploy/gcp/cloudrun-service.yaml @@ -0,0 +1,45 @@ +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: knowledge-explorer + annotations: + run.googleapis.com/ingress: all +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/minScale: "0" + autoscaling.knative.dev/maxScale: "10" + spec: + containerConcurrency: 80 + timeoutSeconds: 300 + containers: + - image: gcr.io/PROJECT_ID/knowledge-explorer:latest + ports: + - name: http1 + containerPort: 8000 + env: + - name: ALLOWED_ORIGINS + value: "*" + - name: FALKORDB_HOST + valueFrom: + secretKeyRef: + name: falkordb-host + key: latest + - name: FALKORDB_PORT + valueFrom: + secretKeyRef: + name: falkordb-port + key: latest + resources: + limits: + cpu: "1" + memory: 512Mi + livenessProbe: + httpGet: + path: /api/health + port: 8000 + initialDelaySeconds: 20 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 diff --git a/deploy/helm/README.md b/deploy/helm/README.md new file mode 100644 index 00000000..bc4d0462 --- /dev/null +++ b/deploy/helm/README.md @@ -0,0 +1,9 @@ +# Helm + +Deploy the Knowledge Explorer chart: + +```bash +helm lint deploy/helm/knowledge-explorer +helm upgrade --install knowledge-explorer deploy/helm/knowledge-explorer --namespace semantica --create-namespace +helm upgrade --install knowledge-explorer deploy/helm/knowledge-explorer --namespace semantica --create-namespace -f deploy/helm/knowledge-explorer/values.prod.yaml +``` diff --git a/deploy/helm/knowledge-explorer/Chart.yaml b/deploy/helm/knowledge-explorer/Chart.yaml new file mode 100644 index 00000000..b0b64c0a --- /dev/null +++ b/deploy/helm/knowledge-explorer/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: knowledge-explorer +description: Semantica Knowledge Explorer deployment chart +type: application +version: 0.1.0 +appVersion: "0.5.0" diff --git a/deploy/helm/knowledge-explorer/README.md b/deploy/helm/knowledge-explorer/README.md new file mode 100644 index 00000000..dd2ab738 --- /dev/null +++ b/deploy/helm/knowledge-explorer/README.md @@ -0,0 +1,9 @@ +# Knowledge Explorer Helm Chart + +```bash +helm lint deploy/helm/knowledge-explorer +helm upgrade --install knowledge-explorer deploy/helm/knowledge-explorer --namespace semantica --create-namespace +helm upgrade --install knowledge-explorer deploy/helm/knowledge-explorer --namespace semantica --create-namespace -f deploy/helm/knowledge-explorer/values.prod.yaml +``` + +Set `autoscaling.enabled=true` to render the HPA. Put sensitive values in Kubernetes Secrets and reference them outside this chart, or pass non-secret env values through `env`. diff --git a/deploy/helm/knowledge-explorer/templates/_helpers.tpl b/deploy/helm/knowledge-explorer/templates/_helpers.tpl new file mode 100644 index 00000000..6680c6c1 --- /dev/null +++ b/deploy/helm/knowledge-explorer/templates/_helpers.tpl @@ -0,0 +1,29 @@ +{{- define "knowledge-explorer.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "knowledge-explorer.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 -}} + +{{- define "knowledge-explorer.labels" -}} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/name: {{ include "knowledge-explorer.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "knowledge-explorer.selectorLabels" -}} +app.kubernetes.io/name: {{ include "knowledge-explorer.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} diff --git a/deploy/helm/knowledge-explorer/templates/configmap.yaml b/deploy/helm/knowledge-explorer/templates/configmap.yaml new file mode 100644 index 00000000..870f674a --- /dev/null +++ b/deploy/helm/knowledge-explorer/templates/configmap.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "knowledge-explorer.fullname" . }} + labels: + {{- include "knowledge-explorer.labels" . | nindent 4 }} +data: + {{- range $key, $value := .Values.env }} + {{ $key }}: {{ $value | quote }} + {{- end }} diff --git a/deploy/helm/knowledge-explorer/templates/deployment.yaml b/deploy/helm/knowledge-explorer/templates/deployment.yaml new file mode 100644 index 00000000..7015fb23 --- /dev/null +++ b/deploy/helm/knowledge-explorer/templates/deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "knowledge-explorer.fullname" . }} + labels: + {{- include "knowledge-explorer.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + {{- include "knowledge-explorer.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "knowledge-explorer.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: explorer + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "knowledge-explorer.fullname" . }} + {{- if .Values.secretEnv }} + env: + {{- range $key, $value := .Values.secretEnv }} + - name: {{ $key }} + valueFrom: + secretKeyRef: + name: {{ $value.secretName }} + key: {{ $value.secretKey }} + {{- end }} + {{- end }} + livenessProbe: + httpGet: + path: {{ .Values.livenessProbe.path }} + port: http + initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.livenessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }} + failureThreshold: {{ .Values.livenessProbe.failureThreshold }} + readinessProbe: + httpGet: + path: {{ .Values.readinessProbe.path }} + port: http + initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.readinessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }} + failureThreshold: {{ .Values.readinessProbe.failureThreshold }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/helm/knowledge-explorer/templates/hpa.yaml b/deploy/helm/knowledge-explorer/templates/hpa.yaml new file mode 100644 index 00000000..ec371209 --- /dev/null +++ b/deploy/helm/knowledge-explorer/templates/hpa.yaml @@ -0,0 +1,22 @@ +{{- if .Values.autoscaling.enabled -}} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "knowledge-explorer.fullname" . }} + labels: + {{- include "knowledge-explorer.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "knowledge-explorer.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} diff --git a/deploy/helm/knowledge-explorer/templates/ingress.yaml b/deploy/helm/knowledge-explorer/templates/ingress.yaml new file mode 100644 index 00000000..e1d9faa4 --- /dev/null +++ b/deploy/helm/knowledge-explorer/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "knowledge-explorer.fullname" . }} + labels: + {{- include "knowledge-explorer.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "knowledge-explorer.fullname" $ }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/helm/knowledge-explorer/templates/service.yaml b/deploy/helm/knowledge-explorer/templates/service.yaml new file mode 100644 index 00000000..ffab036d --- /dev/null +++ b/deploy/helm/knowledge-explorer/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "knowledge-explorer.fullname" . }} + labels: + {{- include "knowledge-explorer.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "knowledge-explorer.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/knowledge-explorer/values.prod.yaml b/deploy/helm/knowledge-explorer/values.prod.yaml new file mode 100644 index 00000000..7305a8e2 --- /dev/null +++ b/deploy/helm/knowledge-explorer/values.prod.yaml @@ -0,0 +1,27 @@ +image: + repository: ghcr.io/semantica-agi/semantica-knowledge-explorer + tag: latest + pullPolicy: IfNotPresent + +ingress: + enabled: true + hosts: + - host: knowledge-explorer.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: knowledge-explorer-tls + hosts: + - knowledge-explorer.example.com + +env: + ALLOWED_ORIGINS: https://knowledge-explorer.example.com + FALKORDB_HOST: falkordb.semantic-data.svc.cluster.local + FALKORDB_PORT: "6379" + +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 75 diff --git a/deploy/helm/knowledge-explorer/values.yaml b/deploy/helm/knowledge-explorer/values.yaml new file mode 100644 index 00000000..cc5b4995 --- /dev/null +++ b/deploy/helm/knowledge-explorer/values.yaml @@ -0,0 +1,83 @@ +replicaCount: 2 + +image: + repository: semantica-knowledge-explorer + pullPolicy: IfNotPresent + tag: latest + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + +service: + type: ClusterIP + port: 80 + targetPort: 8000 + +ingress: + enabled: false + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + hosts: + - host: knowledge-explorer.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: knowledge-explorer-tls + hosts: + - knowledge-explorer.example.com + +env: + ALLOWED_ORIGINS: https://knowledge-explorer.example.com + FALKORDB_HOST: falkordb + FALKORDB_PORT: "6379" + +secretEnv: {} + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + +livenessProbe: + path: /api/health + initialDelaySeconds: 20 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + +readinessProbe: + path: /api/health + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + +autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/deploy/kubernetes/README.md b/deploy/kubernetes/README.md new file mode 100644 index 00000000..72cd595d --- /dev/null +++ b/deploy/kubernetes/README.md @@ -0,0 +1,12 @@ +# Kubernetes + +Apply the raw manifests with Kustomize: + +```bash +cp deploy/kubernetes/secret.yaml.example deploy/kubernetes/secret.yaml +kubectl apply -f deploy/kubernetes/secret.yaml +kubectl apply -k deploy/kubernetes +kubectl -n semantica rollout status deployment/knowledge-explorer +``` + +Update the image name and ingress host before deploying to production. `secret.yaml` is intentionally ignored from the kustomization; keep only `secret.yaml.example` in git. diff --git a/deploy/kubernetes/configmap.yaml b/deploy/kubernetes/configmap.yaml new file mode 100644 index 00000000..79aea825 --- /dev/null +++ b/deploy/kubernetes/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: knowledge-explorer-config + namespace: semantica +data: + ALLOWED_ORIGINS: "https://knowledge-explorer.example.com" + FALKORDB_HOST: "falkordb" + FALKORDB_PORT: "6379" diff --git a/deploy/kubernetes/deployment.yaml b/deploy/kubernetes/deployment.yaml new file mode 100644 index 00000000..1cdb4730 --- /dev/null +++ b/deploy/kubernetes/deployment.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: knowledge-explorer + namespace: semantica + labels: + app.kubernetes.io/name: knowledge-explorer + app.kubernetes.io/part-of: semantica +spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app.kubernetes.io/name: knowledge-explorer + template: + metadata: + labels: + app.kubernetes.io/name: knowledge-explorer + app.kubernetes.io/part-of: semantica + spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: explorer + image: semantica-knowledge-explorer:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8000 + envFrom: + - configMapRef: + name: knowledge-explorer-config + - secretRef: + name: knowledge-explorer-secrets + optional: true + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 20 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL diff --git a/deploy/kubernetes/ingress.yaml b/deploy/kubernetes/ingress.yaml new file mode 100644 index 00000000..a9b1c0cd --- /dev/null +++ b/deploy/kubernetes/ingress.yaml @@ -0,0 +1,24 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: knowledge-explorer + namespace: semantica + annotations: + kubernetes.io/ingress.class: nginx + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + tls: + - hosts: + - knowledge-explorer.example.com + secretName: knowledge-explorer-tls + rules: + - host: knowledge-explorer.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: knowledge-explorer + port: + name: http diff --git a/deploy/kubernetes/kustomization.yaml b/deploy/kubernetes/kustomization.yaml new file mode 100644 index 00000000..11a22ebe --- /dev/null +++ b/deploy/kubernetes/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - configmap.yaml + - deployment.yaml + - service.yaml + - ingress.yaml diff --git a/deploy/kubernetes/namespace.yaml b/deploy/kubernetes/namespace.yaml new file mode 100644 index 00000000..ebca2fdc --- /dev/null +++ b/deploy/kubernetes/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: semantica diff --git a/deploy/kubernetes/secret.yaml.example b/deploy/kubernetes/secret.yaml.example new file mode 100644 index 00000000..ea6098f2 --- /dev/null +++ b/deploy/kubernetes/secret.yaml.example @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: knowledge-explorer-secrets + namespace: semantica +type: Opaque +stringData: + FALKORDB_PASSWORD: "replace-me-if-your-falkordb-requires-auth" diff --git a/deploy/kubernetes/service.yaml b/deploy/kubernetes/service.yaml new file mode 100644 index 00000000..c80af091 --- /dev/null +++ b/deploy/kubernetes/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: knowledge-explorer + namespace: semantica + labels: + app.kubernetes.io/name: knowledge-explorer +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: knowledge-explorer + ports: + - name: http + port: 80 + targetPort: http diff --git a/deploy/railway/README.md b/deploy/railway/README.md new file mode 100644 index 00000000..a42163ca --- /dev/null +++ b/deploy/railway/README.md @@ -0,0 +1,15 @@ +# Railway + +Deploys the Knowledge Explorer from the root `Dockerfile` and checks `/api/health`. + +```bash +railway login +railway init +railway add --database redis +railway variable --set "FALKORDB_HOST=${{Redis.REDISHOST}}" +railway variable --set "FALKORDB_PORT=${{Redis.REDISPORT}}" +railway variable --set "ALLOWED_ORIGINS=https://${{RAILWAY_PUBLIC_DOMAIN}}" +railway up +``` + +The Redis plugin variables are wired to the requested FalkorDB env names for deployment compatibility. The Explorer currently reads these settings but does not persist graph state to FalkorDB. diff --git a/deploy/railway/railway.toml b/deploy/railway/railway.toml new file mode 100644 index 00000000..138f96b4 --- /dev/null +++ b/deploy/railway/railway.toml @@ -0,0 +1,9 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/api/health" +healthcheckTimeout = 300 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 10 diff --git a/deploy/render/README.md b/deploy/render/README.md new file mode 100644 index 00000000..e252527a --- /dev/null +++ b/deploy/render/README.md @@ -0,0 +1,11 @@ +# Render + +This Blueprint provisions a Docker web service plus a Render Key Value instance and wires the datastore host/port into the Explorer env vars. + +```bash +render login +render blueprints validate deploy/render/render.yaml +render blueprint apply deploy/render/render.yaml +``` + +After creation, update `ALLOWED_ORIGINS` in the Render dashboard if you attach a custom domain. diff --git a/deploy/render/render.yaml b/deploy/render/render.yaml new file mode 100644 index 00000000..d0776749 --- /dev/null +++ b/deploy/render/render.yaml @@ -0,0 +1,29 @@ +services: + - type: web + name: semantica-knowledge-explorer + runtime: docker + plan: starter + region: oregon + dockerfilePath: ./Dockerfile + dockerContext: . + healthCheckPath: /api/health + envVars: + - key: ALLOWED_ORIGINS + value: https://semantica-knowledge-explorer.onrender.com + - key: FALKORDB_HOST + fromService: + type: keyvalue + name: semantica-explorer-redis + property: host + - key: FALKORDB_PORT + fromService: + type: keyvalue + name: semantica-explorer-redis + property: port + + - type: keyvalue + name: semantica-explorer-redis + plan: starter + ipAllowList: [] + maxmemoryPolicy: noeviction + persistenceMode: snapshot diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..5162d6d3 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,42 @@ +services: + explorer: + command: + - python + - -m + - uvicorn + - semantica.explorer.app:app + - --host + - 0.0.0.0 + - --port + - "8000" + - --reload + - --reload-dir + - /app/semantica + environment: + ALLOWED_ORIGINS: http://localhost:5173,http://127.0.0.1:5173,http://localhost:8000,http://127.0.0.1:8000 + FALKORDB_HOST: falkordb + FALKORDB_PORT: "6379" + volumes: + - ./semantica:/app/semantica + - ./pyproject.toml:/app/pyproject.toml:ro + + frontend: + image: node:22-alpine + working_dir: /app/explorer + command: sh -c "npm ci && npm run dev -- --host 0.0.0.0" + environment: + VITE_EXPLORER_API_TARGET: http://explorer:8000 + VITE_EXPLORER_WS_TARGET: ws://explorer:8000 + ports: + - "5173:5173" + volumes: + - ./explorer:/app/explorer + - explorer_node_modules:/app/explorer/node_modules + depends_on: + explorer: + condition: service_started + networks: + - semantica + +volumes: + explorer_node_modules: diff --git a/docker-compose.yml b/docker-compose.yml index 27503d40..746b6bf3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,35 @@ services: + explorer: + build: + context: . + dockerfile: Dockerfile + image: semantica-knowledge-explorer:latest + environment: + FALKORDB_HOST: falkordb + FALKORDB_PORT: "6379" + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:8000,http://127.0.0.1:8000} + depends_on: + falkordb: + condition: service_started + ports: + - "8000:8000" + networks: + - semantica + restart: unless-stopped + falkordb: image: falkordb/falkordb:latest ports: - "6379:6379" volumes: - falkordb_data:/data - restart: always + networks: + - semantica + restart: unless-stopped + +networks: + semantica: + driver: bridge volumes: falkordb_data: diff --git a/explorer/vite.config.ts b/explorer/vite.config.ts index 0ada812a..bdc253f0 100644 --- a/explorer/vite.config.ts +++ b/explorer/vite.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' +const apiTarget = process.env.VITE_EXPLORER_API_TARGET ?? 'http://127.0.0.1:8000' +const wsTarget = process.env.VITE_EXPLORER_WS_TARGET ?? apiTarget.replace(/^http/, 'ws') + // https://vite.dev/config/ export default defineConfig({ plugins: [ @@ -57,11 +60,11 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://127.0.0.1:8000', + target: apiTarget, changeOrigin: true, }, '/ws': { - target: 'ws://127.0.0.1:8000', + target: wsTarget, ws: true, }, }, diff --git a/semantica/explorer/app.py b/semantica/explorer/app.py index e1242e5a..d6a8f888 100644 --- a/semantica/explorer/app.py +++ b/semantica/explorer/app.py @@ -1,4 +1,4 @@ -""" +""" Semantica Explorer FastAPI application factory. """ @@ -14,10 +14,36 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from .. import __version__ +from ..context.context_graph import ContextGraph from .session import GraphSession from .ws import ConnectionManager +def _read_int_env(name: str, default: int) -> int: + raw_value = os.environ.get(name) + if raw_value is None or raw_value.strip() == "": + return default + try: + return int(raw_value) + except ValueError: + return default + + +def _read_explorer_settings() -> dict: + raw_origins = ( + os.environ.get("ALLOWED_ORIGINS") + or os.environ.get("EXPLORER_CORS_ORIGINS") + or "http://localhost:5173,http://127.0.0.1:5173" + ) + return { + "allowed_origins": [ + origin.strip() for origin in raw_origins.split(",") if origin.strip() + ], + "falkordb_host": os.environ.get("FALKORDB_HOST", "localhost"), + "falkordb_port": _read_int_env("FALKORDB_PORT", 6379), + } + + def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None: previous_callback = getattr(session.graph, "mutation_callback", None) @@ -43,13 +69,15 @@ def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None: def create_app(session: Optional[GraphSession] = None) -> FastAPI: + active_session = session or GraphSession(ContextGraph(advanced_analytics=False)) + settings = _read_explorer_settings() + @asynccontextmanager async def lifespan(app: FastAPI): app.state.event_loop = asyncio.get_running_loop() app.state.ws_manager = ConnectionManager() - if session is not None: - app.state.session = session - _install_mutation_bridge(app, session) + app.state.session = active_session + _install_mutation_bridge(app, active_session) yield app = FastAPI( @@ -59,10 +87,11 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI: lifespan=lifespan, ) - _raw_origins = os.environ.get( - "EXPLORER_CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173" - ) - _cors_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()] + app.state.explorer_settings = settings + app.state.falkordb_host = settings["falkordb_host"] + app.state.falkordb_port = settings["falkordb_port"] + app.state.allowed_origins = settings["allowed_origins"] + # allow_credentials lets browsers send cookies/auth headers cross-origin. # The Explorer has no authentication, so credentials serve no purpose and # enabling them when origins are broadened creates cross-site request risk. @@ -71,7 +100,7 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI: _allow_credentials = os.environ.get("EXPLORER_CORS_CREDENTIALS", "false").lower() == "true" app.add_middleware( CORSMiddleware, - allow_origins=_cors_origins, + allow_origins=settings["allowed_origins"], allow_credentials=_allow_credentials, allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_headers=["Content-Type", "Authorization"], @@ -169,7 +198,7 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI: @app.get("/api/health") async def health(): - return {"status": "healthy"} + return {"status": "ok"} @app.get("/api/info") async def info(): diff --git a/tests/explorer/test_explorer_api.py b/tests/explorer/test_explorer_api.py index dc1c4bb3..16a89f1b 100644 --- a/tests/explorer/test_explorer_api.py +++ b/tests/explorer/test_explorer_api.py @@ -1,4 +1,4 @@ -"""Integration tests for the explorer API.""" +"""Integration tests for the explorer API.""" import json from pathlib import Path @@ -151,7 +151,7 @@ class TestHealthInfo: def test_health(self, client): response = client.get("/api/health") assert response.status_code == 200 - assert response.json()["status"] == "healthy" + assert response.json() == {"status": "ok"} def test_info(self, client): response = client.get("/api/info") @@ -161,6 +161,40 @@ class TestHealthInfo: assert payload["status"] == "active" assert payload["version"] + def test_env_settings_are_read_from_supported_names(self, monkeypatch): + monkeypatch.setenv( + "ALLOWED_ORIGINS", + "https://app.example.com, https://team.example.com", + ) + monkeypatch.setenv("FALKORDB_HOST", "falkordb.internal") + monkeypatch.setenv("FALKORDB_PORT", "6380") + + app = create_app() + + assert app.state.allowed_origins == [ + "https://app.example.com", + "https://team.example.com", + ] + assert app.state.falkordb_host == "falkordb.internal" + assert app.state.falkordb_port == 6380 + + def test_env_settings_fall_back_to_legacy_cors_name(self, monkeypatch): + monkeypatch.delenv("ALLOWED_ORIGINS", raising=False) + monkeypatch.setenv("EXPLORER_CORS_ORIGINS", "https://legacy.example.com") + + app = create_app() + + assert app.state.allowed_origins == ["https://legacy.example.com"] + + def test_default_app_initializes_empty_graph_session(self): + with TestClient(create_app()) as test_client: + response = test_client.get("/api/graph/nodes") + + assert response.status_code == 200 + payload = response.json() + assert payload["nodes"] == [] + assert payload["total"] == 0 + class TestGraphNodes: def test_list_nodes(self, client):